From a70e899283cdb255d6c9010a890ca3ed49a34357 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 23 May 2026 11:58:27 +0200 Subject: [PATCH 01/52] Add radius jewel finder Add the Tree tab Radius Jewel Finder UI, data catalog, computation helpers, and manifest entries. --- src/Classes/RadiusJewelCompute.lua | 1061 +++++++++++ src/Classes/RadiusJewelData.lua | 1113 ++++++++++++ src/Classes/RadiusJewelFinder.lua | 2630 ++++++++++++++++++++++++++++ src/Classes/TreeTab.lua | 14 +- 4 files changed, 4816 insertions(+), 2 deletions(-) create mode 100644 src/Classes/RadiusJewelCompute.lua create mode 100644 src/Classes/RadiusJewelData.lua create mode 100644 src/Classes/RadiusJewelFinder.lua diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua new file mode 100644 index 0000000000..650c6e0740 --- /dev/null +++ b/src/Classes/RadiusJewelCompute.lua @@ -0,0 +1,1061 @@ +-- Path of Building +-- +-- Module: Radius Jewel Compute +-- Compute methods for the Radius Jewel Finder — calcFunc-based impact evaluation +-- across all socket/jewel pairs. +-- +-- Usage: +-- local attachCompute = LoadModule("Classes/RadiusJewelCompute") +-- attachCompute(RadiusJewelFinderClass, { extractTooltipStats, normalizeImpactStat, calculateImpactPercent, mustGetUniqueRawText }) +-- +local ipairs = ipairs +local pairs = pairs +local t_insert = table.insert +local t_sort = table.sort +local s_format = string.format + +return function(Class, helpers) + +local extractTooltipStats = helpers.extractTooltipStats +local normalizeImpactStat = helpers.normalizeImpactStat +local calculateImpactPercent = helpers.calculateImpactPercent +local mustGetUniqueRawText = helpers.mustGetUniqueRawText + +-- ───────────────────────────────────────────────────────────────────────────── +-- Local helpers +-- ───────────────────────────────────────────────────────────────────────────── + +local function progressTick(progress, done, total, label) + if progress and progress.tick then + progress:tick(done, total, label) + end +end + +local function progressChild(progress, startFraction, spanFraction) + if progress and progress.child then + return progress:child(startFraction, spanFraction) + end + return progress +end + +local function isDisconnectedPassiveCandidateNode(node, keystoneOnly, notableOrKeystoneOnly) + if not node then + return false + end + if node.ascendancyName then + return false + end + if node.type == "Socket" or node.type == "ClassStart" or node.type == "AscendClassStart" or node.type == "Mastery" then + return false + end + if keystoneOnly then + return node.type == "Keystone" + end + if notableOrKeystoneOnly then + return node.type == "Keystone" or node.type == "Notable" + end + return true +end + +local function getPassiveNodeLabel(node) + return node.dn or node.name or tostring(node.id or "?") +end + +local function buildChosenNodesSummary(nodes, variantLabel) + local labels = { } + for _, node in ipairs(nodes) do + t_insert(labels, getPassiveNodeLabel(node)) + end + t_sort(labels) + local prefix = #labels == 1 and "1 node" or s_format("%d nodes", #labels) + if #labels == 0 then + return variantLabel and (variantLabel .. " | jewel only") or "jewel only" + end + local summary = labels[1] + if #labels >= 2 then + summary = summary .. ", " .. labels[2] + end + if #labels > 2 then + summary = summary .. s_format(", +%d more", #labels - 2) + end + if variantLabel and variantLabel ~= "" then + return s_format("%s | %s: %s", variantLabel, prefix, summary) + end + return s_format("%s: %s", prefix, summary) +end + +local function copyNodeList(nodes) + local out = { } + for i, node in ipairs(nodes) do + out[i] = node + end + return out +end + +local function buildNodeLabelList(nodes) + local labels = { } + for _, node in ipairs(nodes or { }) do + if type(node) == "table" then + if node.label then + t_insert(labels, node.label) + else + t_insert(labels, getPassiveNodeLabel(node)) + end + else + t_insert(labels, tostring(node)) + end + end + return labels +end + +local function buildNodeEntries(nodes) + local entries = { } + for _, node in ipairs(nodes or { }) do + if type(node) == "table" then + t_insert(entries, { + label = getPassiveNodeLabel(node), + nodeId = node.id, + }) + else + t_insert(entries, { + label = tostring(node), + }) + end + end + t_sort(entries, function(a, b) + return (a.label or "") < (b.label or "") + end) + return entries +end + +local function buildReplacementItem(slot) + local item = new("Item"):Item("Rarity: Normal\nCobalt Jewel") + item:BuildModList() + if slot and slot.selItemId == 0 then + item.jewelSocketSource = "empty" + end + return item +end + +local function buildDisconnectedPassivePlanStep(baseOutput, baseValue, value, compareOutput, chosenNodes, variantLabel) + local snapshotNodes = copyNodeList(chosenNodes) + return { + value = value, + delta = value - baseValue, + baseOutput = extractTooltipStats(baseOutput), + compareOutput = extractTooltipStats(compareOutput), + chosenNodes = snapshotNodes, + resultNodes = buildNodeEntries(snapshotNodes), + resultNodeLabels = buildNodeLabelList(snapshotNodes), + addedNodeCount = #snapshotNodes, + detailText = buildChosenNodesSummary(snapshotNodes, variantLabel), + } +end + +-- Exported for the UI to build compute result rows +local function buildDisplayedDisconnectedPassivePlans(result, socketBasePoints, baseline) + if not result.planSteps or #result.planSteps == 0 then + return { result } + end + local displayedPlans = { } + local bestPctPerPoint = -math.huge + for _, step in ipairs(result.planSteps) do + local totalPoints = socketBasePoints + (step.addedNodeCount or 0) + local pct = calculateImpactPercent(step.delta, baseline) + local pctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct + if pctPerPoint > bestPctPerPoint + 1e-9 then + t_insert(displayedPlans, step) + bestPctPerPoint = pctPerPoint + end + end + local finalPlan = result + local lastDisplayed = displayedPlans[#displayedPlans] + if not lastDisplayed or (lastDisplayed.addedNodeCount or 0) ~= (finalPlan.addedNodeCount or 0) then + t_insert(displayedPlans, finalPlan) + end + return displayedPlans +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Class methods +-- ───────────────────────────────────────────────────────────────────────────── + +function Class:buildSocketReplacementContext(calcFunc, socketId) + local socketNode = self.build.spec.nodes[socketId] or self.build.spec.tree.nodes[socketId] + if not socketNode then + return nil + end + local occupancy = self:getSocketOccupancyInfo(socketId) + local slotName = "Jewel " .. tostring(socketId) + local baselineItem = occupancy.isOccupied and occupancy.item or buildReplacementItem(occupancy.slot) + local baselineOutput = calcFunc({ + addNodes = { [socketNode] = true }, + repSlotName = slotName, + repItem = baselineItem, + }) + return { + socketNode = socketNode, + slotName = slotName, + occupancy = occupancy, + baselineItem = baselineItem, + baselineOutput = baselineOutput, + replacedItemLabel = occupancy.replacedItemLabel, + storedUnallocatedItemLabel = occupancy.storedUnallocatedItemLabel, + } +end + +function Class:getSocketDistanceToClassStart(socketId) + local spec = self.build.spec + local socketNode = spec.nodes[socketId] + if not socketNode then + return 0 + end + if socketNode.alloc and socketNode.connectedToStart then + return socketNode.distanceToClassStart or 0 + end + + local targetNodeId = spec.curClass.startNodeId + local nodeDistanceToRoot = { [socketNode.id] = 0 } + local queue = { socketNode } + local outIndex, inIndex = 1, 2 + while outIndex < inIndex do + local node = queue[outIndex] + outIndex = outIndex + 1 + local curDist = nodeDistanceToRoot[node.id] + 1 + for _, other in ipairs(node.linked) do + if other.id == targetNodeId then + return curDist - 1 + end + if node.type ~= "Mastery" + and other.type ~= "ClassStart" + and other.type ~= "AscendClassStart" + and not nodeDistanceToRoot[other.id] + and (node.ascendancyName == other.ascendancyName or (nodeDistanceToRoot[node.id] == 0 and not other.ascendancyName)) then + nodeDistanceToRoot[other.id] = curDist + queue[inIndex] = other + inIndex = inIndex + 1 + end + end + end + + return 0 +end + +-- Candidates are unallocated passives a disconnected-passive jewel may add before scoring. +function Class:collectDisconnectedPassiveCandidates(socketNode, options) + local allocNodes = self.build.spec.allocNodes + local candidates = { } + local seen = { } + local sourceNodes + if options.collectNodes then + sourceNodes = options.collectNodes(socketNode) + else + sourceNodes = socketNode and socketNode.nodesInRadius and options.radiusIndex and socketNode.nodesInRadius[options.radiusIndex] + end + if not sourceNodes then + return candidates + end + for nodeId, node in pairs(sourceNodes) do + if not seen[nodeId] and not allocNodes[nodeId] and isDisconnectedPassiveCandidateNode(node, options.keystoneOnly, options.notableOrKeystoneOnly) then + t_insert(candidates, node) + seen[nodeId] = true + end + end + t_sort(candidates, function(a, b) + if a.type ~= b.type then + if a.type == "Keystone" then + return true + end + if b.type == "Keystone" then + return false + end + if a.type == "Notable" then + return true + end + if b.type == "Notable" then + return false + end + end + return getPassiveNodeLabel(a) < getPassiveNodeLabel(b) + end) + return candidates +end + +function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, baseOutput, baseValue, socketNode, slotName, item, impactStat, candidates, variantLabel, progressLabel, progress, maxAdditionalNodes) + impactStat = normalizeImpactStat(impactStat) + local addNodes = { [socketNode] = true } + local function calculate(extraNode) + local nextNodes = copyTable(addNodes, true) + if extraNode then + nextNodes[extraNode] = true + end + local output = calcFunc({ + addNodes = nextNodes, + repSlotName = slotName, + repItem = item, + }) + return output, self:getImpactValue(impactStat, output) + end + + local currentOutput, currentValue = calculate() + local chosenNodes = { } + local chosenNodeIds = { } + if maxAdditionalNodes and maxAdditionalNodes <= 0 then + return buildDisconnectedPassivePlanStep(baseOutput, baseValue, currentValue, currentOutput, chosenNodes, variantLabel) + end + local planSteps = { } + + while true do + if maxAdditionalNodes and #chosenNodes >= maxAdditionalNodes then + break + end + local bestCandidate + for candidateIndex, node in ipairs(candidates) do + progressTick(progress, candidateIndex, #candidates, progressLabel) + if not chosenNodeIds[node.id] then + local output, value = calculate(node) + -- Marginal delta is this node's extra gain over the current greedy plan. + local marginalDelta = value - currentValue + if not bestCandidate + or marginalDelta > bestCandidate.marginalDelta + or (marginalDelta == bestCandidate.marginalDelta and getPassiveNodeLabel(node) < getPassiveNodeLabel(bestCandidate.node)) then + bestCandidate = { + node = node, + output = output, + value = value, + marginalDelta = marginalDelta, + } + end + end + end + if not bestCandidate or bestCandidate.marginalDelta <= 0 then + break + end + chosenNodeIds[bestCandidate.node.id] = true + addNodes[bestCandidate.node] = true + t_insert(chosenNodes, bestCandidate.node) + currentOutput = bestCandidate.output + currentValue = bestCandidate.value + t_insert(planSteps, buildDisconnectedPassivePlanStep(baseOutput, baseValue, currentValue, currentOutput, chosenNodes, variantLabel)) + end + + local result = buildDisconnectedPassivePlanStep(baseOutput, baseValue, currentValue, currentOutput, chosenNodes, variantLabel) + result.planSteps = planSteps + return result +end + +function Class:computeDisconnectedPassiveFastPlan(calcFunc, baseOutput, baseValue, socketNode, slotName, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold) + impactStat = normalizeImpactStat(impactStat) + local jewelOnlyOutput, jewelOnlyValue + local function ensureJewelOnly() + if not jewelOnlyOutput then + jewelOnlyOutput = calcFunc({ + addNodes = { [socketNode] = true }, + repSlotName = slotName, + repItem = item, + }) + jewelOnlyValue = self:getImpactValue(impactStat, jewelOnlyOutput) + end + end + if maxAdditionalNodes and maxAdditionalNodes <= 0 then + ensureJewelOnly() + local chosenNodes = { } + return buildDisconnectedPassivePlanStep(baseOutput, baseValue, jewelOnlyValue, jewelOnlyOutput, chosenNodes, variantLabel) + end + local scoredCandidates = { } + for candidateIndex, node in ipairs(candidates) do + progressTick(progress, candidateIndex, #candidates, progressLabel) + local delta = deltaCache[node.id] + if delta == nil then + ensureJewelOnly() + local output = calcFunc({ + addNodes = { [socketNode] = true, [node] = true }, + repSlotName = slotName, + repItem = item, + }) + delta = self:getImpactValue(impactStat, output) - jewelOnlyValue + deltaCache[node.id] = delta + end + if delta > 0 then + t_insert(scoredCandidates, { + node = node, + delta = delta, + }) + end + end + t_sort(scoredCandidates, function(a, b) + if a.delta ~= b.delta then + return a.delta > b.delta + end + return getPassiveNodeLabel(a.node) < getPassiveNodeLabel(b.node) + end) + + local chosenNodes = { } + local estimatedDelta = 0 + for i, entry in ipairs(scoredCandidates) do + if maxAdditionalNodes and i > maxAdditionalNodes then + break + end + t_insert(chosenNodes, entry.node) + estimatedDelta = estimatedDelta + entry.delta + end + + -- Early pruning: if the sum of individual gains can't beat the current best, skip the slower final calcFunc + if earlyPruneThreshold and estimatedDelta <= earlyPruneThreshold then + return { delta = estimatedDelta, pruned = true } + end + + local addNodes = { [socketNode] = true } + for _, node in ipairs(chosenNodes) do + addNodes[node] = true + end + + if skipPlanSteps then + local finalOutput = calcFunc({ + addNodes = addNodes, + repSlotName = slotName, + repItem = item, + }) + local finalValue = self:getImpactValue(impactStat, finalOutput) + return buildDisconnectedPassivePlanStep(baseOutput, baseValue, finalValue, finalOutput, chosenNodes, variantLabel) + end + + local planSteps = { } + local prefixNodes = { } + local prefixAddNodes = { [socketNode] = true } + local lastOutput, lastValue + for _, node in ipairs(chosenNodes) do + t_insert(prefixNodes, node) + prefixAddNodes[node] = true + lastOutput = calcFunc({ + addNodes = prefixAddNodes, + repSlotName = slotName, + repItem = item, + }) + lastValue = self:getImpactValue(impactStat, lastOutput) + t_insert(planSteps, buildDisconnectedPassivePlanStep(baseOutput, baseValue, lastValue, lastOutput, prefixNodes, variantLabel)) + end + if not lastOutput then + ensureJewelOnly() + lastOutput = jewelOnlyOutput + lastValue = jewelOnlyValue + end + + local result = buildDisconnectedPassivePlanStep(baseOutput, baseValue, lastValue, lastOutput, chosenNodes, variantLabel) + result.planSteps = planSteps + return result +end + +function Class:computeSocketImpact(sockets, rawText, impactStat, progress, maxTotalPoints, occupiedMode) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + + local results = { } + for socketIndex, socket in ipairs(sockets) do + progressTick(progress, socketIndex - 1, #sockets, socket.label) + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode) + local socketBasePoints = self:getSocketBasePoints(socket, occupancy) + if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then + local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) + local slotName = replacementContext.slotName + local item = new("Item"):Item("Rarity: Unique\n" .. rawText) + item:BuildModList() + local output = calcFunc({ + addNodes = { [replacementContext.socketNode] = true }, + repSlotName = slotName, + repItem = item, + }) + local value = self:getImpactValue(impactStat, output) + local delta = self:calculateImpactDelta(impactStat, replacementContext.baselineOutput, output) + t_insert(results, { + socket = socket, + value = value, + delta = delta, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + baseOutput = extractTooltipStats(replacementContext.baselineOutput), + compareOutput = extractTooltipStats(output), + }) + end + end + + t_sort(results, function(a, b) return a.delta > b.delta end) + return results, realBaseline +end + +function Class:computeBestVariantSocketImpact(sockets, variants, impactStat, progress, maxTotalPoints, occupiedMode) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + + local results = { } + for socketIndex, socket in ipairs(sockets) do + progressTick(progress, socketIndex - 1, #sockets, socket.label) + local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets) + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode) + local socketBasePoints = self:getSocketBasePoints(socket, occupancy) + if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then + local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) + local slotName = replacementContext.slotName + local socketNode = replacementContext.socketNode + local bestResult + for variantIndex, variant in ipairs(variants) do + progressTick(socketProgress, variantIndex, #variants, socket.label .. " | " .. variant.name) + local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText) + item:BuildModList() + local output = calcFunc({ + addNodes = { [socketNode] = true }, + repSlotName = slotName, + repItem = item, + }) + local value = self:getImpactValue(impactStat, output) + local delta = self:calculateImpactDelta(impactStat, replacementContext.baselineOutput, output) + if not bestResult or delta > bestResult.delta then + bestResult = { + socket = socket, + variant = variant, + variantIdx = variantIndex, + value = value, + delta = delta, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + baseOutput = extractTooltipStats(replacementContext.baselineOutput), + compareOutput = extractTooltipStats(output), + } + end + end + if bestResult then + t_insert(results, bestResult) + end + progressTick(socketProgress, 1, 1, socket.label) + end + end + + t_sort(results, function(a, b) return a.delta > b.delta end) + return results, realBaseline +end + +function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + local statField = impactStat.field + local radiusLookup = { } + for i, radius in ipairs(data.jewelRadius) do + if radius.inner == 0 and not radiusLookup[radius.label] then + radiusLookup[radius.label] = i + end + end + + local isMassiveRadius = variant and variant.isMassiveRadius + local keystoneOnly = variant and variant.keystoneOnly or false + local rawText = (variant and variant.rawText) or mustGetUniqueRawText("Intuitive Leap") + + local function collectMassiveNodes(socketNode) + local nodes = { } + if not socketNode or not socketNode.nodesInRadius then + return nodes + end + for idx, radius in ipairs(data.jewelRadius) do + if radius.outer <= 2400 and socketNode.nodesInRadius[idx] then + for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do + nodes[nodeId] = node + end + end + end + return nodes + end + + local candidateOptions = isMassiveRadius and { + collectNodes = collectMassiveNodes, + keystoneOnly = keystoneOnly, + } or { + radiusIndex = radiusLookup["Small"], + keystoneOnly = false, + } + + local variantKey = variant and variant.name or "normal" + local results = { } + for socketIndex, socket in ipairs(sockets) do + progressTick(progress, socketIndex - 1, #sockets, socket.label) + local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets) + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode) + local socketBasePoints = self:getSocketBasePoints(socket, occupancy) + if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then + local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) + local socketNode = replacementContext.socketNode + local slotName = replacementContext.slotName + local item = new("Item"):Item("Rarity: Unique\n" .. rawText) + item:BuildModList() + local candidates = self:collectDisconnectedPassiveCandidates(socketNode, candidateOptions) + if #candidates > 0 then + local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil + local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) + local result + if methodId == "fast" then + local cacheKey = s_format("IL|%s|%s", statField, variantKey) + planCache[cacheKey] = planCache[cacheKey] or { } + result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, planCache[cacheKey], socket.label, socketProgress, maxAdditionalNodes, skipPlanSteps) + else + result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, socket.label, socketProgress, maxAdditionalNodes) + end + result.socket = socket + result.replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil + result.storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil + t_insert(results, result) + end + progressTick(socketProgress, 1, 1, socket.label) + end + end + + t_sort(results, function(a, b) return a.delta > b.delta end) + return results, realBaseline +end + +function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVariants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + local statField = impactStat.field + local results = { } + + -- Pre-build items per ring variant (avoid re-creating inside the socket loop) + local threadRawText = mustGetUniqueRawText("Thread of Hope") + local threadItems = { } + for variantIndex in ipairs(threadVariants) do + local item = new("Item"):Item("Rarity: Unique\n" .. threadRawText) + item.variant = variantIndex + item:BuildModList() + threadItems[variantIndex] = item + end + + -- Pass 1: find best ring variant per socket (skip plan steps, with early pruning) + local pendingPlanSteps = { } + for socketIndex, socket in ipairs(sockets) do + progressTick(progress, socketIndex - 1, #sockets, socket.label) + local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets) + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode) + local socketBasePoints = self:getSocketBasePoints(socket, occupancy) + if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then + local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) + local socketNode = replacementContext.socketNode + local slotName = replacementContext.slotName + local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) + local bestResult + local bestVariantIndex, bestCandidates + for variantIndex, threadVariant in ipairs(threadVariants) do + local variantProgress = progressChild(socketProgress, (variantIndex - 1) / #threadVariants, 1 / #threadVariants) + local item = threadItems[variantIndex] + local candidates = self:collectDisconnectedPassiveCandidates(socketNode, { + radiusIndex = threadVariant.radiusIndex, + notableOrKeystoneOnly = skipPlanSteps or methodId == "fast", + }) + if #candidates > 0 then + local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil + local earlyPruneThreshold = bestResult and bestResult.delta or nil + local result + if methodId == "fast" then + local cacheKey = s_format("ThreadOfHope|%s", statField) + planCache[cacheKey] = planCache[cacheKey] or { } + result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, threadVariant.name .. " Ring", planCache[cacheKey], socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes, true, earlyPruneThreshold) + else + result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, threadVariant.name .. " Ring", socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes) + end + if not result.pruned then + result.variant = threadVariant + if not bestResult + or result.delta > bestResult.delta + or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount) + or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and threadVariant.radiusIndex < bestResult.variant.radiusIndex) then + bestResult = result + bestVariantIndex = variantIndex + bestCandidates = candidates + end + end + end + end + if bestResult then + bestResult.socket = socket + bestResult.replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil + bestResult.storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil + t_insert(results, bestResult) + if not skipPlanSteps and methodId == "fast" and bestVariantIndex then + t_insert(pendingPlanSteps, { + replacementContext = replacementContext, + socketBaseline = socketBaseline, + bestVariantIndex = bestVariantIndex, + bestCandidates = bestCandidates, + socketBasePoints = socketBasePoints, + resultIndex = #results, + }) + end + end + progressTick(socketProgress, 1, 1, socket.label) + end + end + + t_sort(results, function(a, b) + if a.delta ~= b.delta then + return a.delta > b.delta + end + return a.variant.radiusIndex < b.variant.radiusIndex + end) + + -- Pass 2: compute plan steps only for top results (single-jewel mode) + if #pendingPlanSteps > 0 then + -- Build lookup: which result indices need plan steps (top 5 by delta) + local topResultIndices = { } + for i = 1, math.min(5, #results) do + topResultIndices[results[i]] = true + end + for _, pending in ipairs(pendingPlanSteps) do + local result = results[pending.resultIndex] + -- resultIndex may point to another row after sorting; check by reference + if not topResultIndices[result] then + goto continuePending + end + local replacementContext = pending.replacementContext + local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - pending.socketBasePoints, 0) or nil + local cacheKey = s_format("ThreadOfHope|%s", statField) + local fullResult = self:computeDisconnectedPassiveFastPlan( + calcFunc, + replacementContext.baselineOutput, + pending.socketBaseline, + replacementContext.socketNode, + replacementContext.slotName, + threadItems[pending.bestVariantIndex], + impactStat, + pending.bestCandidates, + threadVariants[pending.bestVariantIndex].name .. " Ring", + planCache[cacheKey], + nil, + nil, + maxAdditionalNodes, + false, + nil + ) + fullResult.variant = threadVariants[pending.bestVariantIndex] + fullResult.socket = result.socket + fullResult.replacedItemLabel = result.replacedItemLabel + fullResult.storedUnallocatedItemLabel = result.storedUnallocatedItemLabel + -- Replace in-place in results + for i, r in ipairs(results) do + if r == result then + results[i] = fullResult + break + end + end + ::continuePending:: + end + end + + return results, realBaseline +end + +function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants, progress, maxTotalPoints, occupiedMode) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + local results = { } + + for socketIndex, socket in ipairs(sockets) do + progressTick(progress, socketIndex - 1, #sockets, socket.label) + local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets) + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode) + local socketBasePoints = self:getSocketBasePoints(socket, occupancy) + if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then + local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) + local socketNode = replacementContext.socketNode + local slotName = replacementContext.slotName + local splitDistance = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id) + local previousDistance = socketNode.distanceToClassStart + + socketNode.distanceToClassStart = splitDistance + local baselineOutput = calcFunc({ + addNodes = { [socketNode] = true }, + repSlotName = slotName, + repItem = replacementContext.baselineItem, + }) + + local bestResult + for variantIdx, variant in ipairs(variants) do + progressTick(socketProgress, variantIdx, #variants, socket.label .. " | " .. variant.name) + local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText) + item:BuildModList() + local output = calcFunc({ + addNodes = { [socketNode] = true }, + repSlotName = slotName, + repItem = item, + }) + local value = self:getImpactValue(impactStat, output) + local delta = self:calculateImpactDelta(impactStat, baselineOutput, output) + if not bestResult or delta > bestResult.delta then + bestResult = { + socket = socket, + variant = variant, + variantIdx = variantIdx, + value = value, + delta = delta, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + baseOutput = extractTooltipStats(baselineOutput), + compareOutput = extractTooltipStats(output), + detailText = s_format("Dist %d | %s", splitDistance, variant.name), + } + end + end + + socketNode.distanceToClassStart = previousDistance + if bestResult then + bestResult.splitDistance = splitDistance + t_insert(results, bestResult) + end + progressTick(socketProgress, 1, 1, socket.label) + end + end + + t_sort(results, function(a, b) + if a.delta ~= b.delta then + return a.delta > b.delta + end + return (a.splitDistance or 0) > (b.splitDistance or 0) + end) + return results, realBaseline +end + +function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + local statField = impactStat.field + local results = { } + local smallRadiusIndex + for i, radius in ipairs(data.jewelRadius) do + if radius.label == "Small" and radius.inner == 0 then + smallRadiusIndex = i + break + end + end + + local notableOrKeystoneOnly = skipPlanSteps or methodId == "fast" + local variantDataByName = { } + for _, variant in ipairs(variants) do + local keystoneNode = self.build.spec.tree.keystoneMap[variant.keystoneName] + if keystoneNode and keystoneNode.nodesInRadius and keystoneNode.nodesInRadius[smallRadiusIndex] then + local candidates = self:collectDisconnectedPassiveCandidates(nil, { + collectNodes = function() + return keystoneNode.nodesInRadius[smallRadiusIndex] + end, + notableOrKeystoneOnly = notableOrKeystoneOnly, + }) + if #candidates > 0 then + local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText) + item:BuildModList() + variantDataByName[variant.name] = { + variant = variant, + item = item, + keystoneNode = keystoneNode, + candidates = candidates, + } + end + end + end + + -- Free sockets with the same remaining points share a representative socket; + -- the computed result is copied back onto every socket in the group below. + local groupedEntries = { } + local groupedOrder = { } + for _, socket in ipairs(sockets) do + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode) + local socketBasePoints = self:getSocketBasePoints(socket, occupancy) + if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then + local remainingPoints = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or -1 + local groupKey = occupancy and occupancy.isOccupied and ("occupied:" .. socket.id) or ("free:" .. tostring(remainingPoints)) + if not groupedEntries[groupKey] then + groupedEntries[groupKey] = { + groupKey = groupKey, + remainingPoints = remainingPoints, + sockets = { }, + representativeSocket = socket, + occupancy = occupancy, + } + t_insert(groupedOrder, groupedEntries[groupKey]) + end + t_insert(groupedEntries[groupKey].sockets, socket) + end + end + if #groupedOrder == 0 then + return results, realBaseline + end + + t_sort(groupedOrder, function(a, b) + if a.remainingPoints ~= b.remainingPoints then + return a.remainingPoints > b.remainingPoints + end + return a.representativeSocket.id < b.representativeSocket.id + end) + local bestResultByGroupKey = { } + local totalPlanCount = #groupedOrder * #variants + local currentPlanIndex = 0 + + -- Track max candidate count across all variants to detect when remaining points can cover all + local maxCandidateCount = 0 + for _, variantData in pairs(variantDataByName) do + if #variantData.candidates > maxCandidateCount then + maxCandidateCount = #variantData.candidates + end + end + local previousFreeResult + for _, groupEntry in ipairs(groupedOrder) do + -- Skip free groups whose remaining points can cover all candidates: reuse the first free group's result + local isFreeGroup = not groupEntry.groupKey:match("^occupied:") + if isFreeGroup and previousFreeResult and groupEntry.remainingPoints >= maxCandidateCount then + bestResultByGroupKey[groupEntry.groupKey] = previousFreeResult + currentPlanIndex = currentPlanIndex + #variants + goto continueGroup + end + local representativeSocket = groupEntry.representativeSocket + local replacementContext = self:buildSocketReplacementContext(calcFunc, representativeSocket.id) + local representativeSocketNode = replacementContext.socketNode + local representativeSlotName = replacementContext.slotName + local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) + local bestResult + for _, variant in ipairs(variants) do + currentPlanIndex = currentPlanIndex + 1 + local planProgress = progressChild(progress, (currentPlanIndex - 1) / totalPlanCount, 1 / totalPlanCount) + local variantData = variantDataByName[variant.name] + if variantData then + local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil + local earlyPruneThreshold = bestResult and bestResult.delta or nil + local result + if methodId == "fast" then + local cacheKey = s_format("IE|%s|%s", statField, variant.name) + planCache[cacheKey] = planCache[cacheKey] or { } + result = self:computeDisconnectedPassiveFastPlan( + calcFunc, + replacementContext.baselineOutput, + socketBaseline, + representativeSocketNode, + representativeSlotName, + variantData.item, + impactStat, + variantData.candidates, + variant.name, + planCache[cacheKey], + variant.name, + planProgress, + maxAdditionalNodes, + true, + earlyPruneThreshold + ) + else + result = self:computeDisconnectedPassiveSimulatedPlan( + calcFunc, + replacementContext.baselineOutput, + socketBaseline, + representativeSocketNode, + representativeSlotName, + variantData.item, + impactStat, + variantData.candidates, + variant.name, + variant.name, + planProgress, + maxAdditionalNodes + ) + end + if not result.pruned then + result.variant = variant + if not bestResult + or result.delta > bestResult.delta + or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount) + or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and variant.name < bestResult.variant.name) then + bestResult = result + end + end + end + progressTick(planProgress, 1, 1, variant.name) + end + bestResultByGroupKey[groupEntry.groupKey] = bestResult + if isFreeGroup and not previousFreeResult then + previousFreeResult = bestResult + end + ::continueGroup:: + end + + for _, groupEntry in ipairs(groupedOrder) do + local bestResult = bestResultByGroupKey[groupEntry.groupKey] + if bestResult then + for _, socket in ipairs(groupEntry.sockets) do + local socketOccupancy = self:getSocketOccupancyInfo(socket.id) + local resultForSocket = copyTableSafe(bestResult, false, true) + resultForSocket.socket = socket + resultForSocket.replacedItemLabel = socketOccupancy and socketOccupancy.replacedItemLabel or nil + resultForSocket.storedUnallocatedItemLabel = socketOccupancy and socketOccupancy.storedUnallocatedItemLabel or nil + t_insert(results, resultForSocket) + end + end + end + + t_sort(results, function(a, b) + if a.delta ~= b.delta then + return a.delta > b.delta + end + return a.variant.name < b.variant.name + end) + + -- Pass 2: compute plan steps for the best variant (single-jewel mode only) + if not skipPlanSteps and methodId == "fast" and #results > 0 then + local topResult = results[1] + local variantData = variantDataByName[topResult.variant.name] + if variantData then + -- Find the group entry for this result to get replacement context + for _, groupEntry in ipairs(groupedOrder) do + local bestResult = bestResultByGroupKey[groupEntry.groupKey] + if bestResult and bestResult.variant.name == topResult.variant.name then + local replacementContext = self:buildSocketReplacementContext(calcFunc, groupEntry.representativeSocket.id) + local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) + local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil + local cacheKey = s_format("IE|%s|%s", statField, topResult.variant.name) + local fullResult = self:computeDisconnectedPassiveFastPlan( + calcFunc, + replacementContext.baselineOutput, + socketBaseline, + replacementContext.socketNode, + replacementContext.slotName, + variantData.item, + impactStat, + variantData.candidates, + topResult.variant.name, + planCache[cacheKey], + nil, + nil, + maxAdditionalNodes, + false, + nil + ) + fullResult.variant = topResult.variant + -- Apply plan steps to all copied results for this variant + for i, r in ipairs(results) do + if r.variant.name == topResult.variant.name then + local updated = copyTableSafe(fullResult, false, true) + updated.socket = r.socket + updated.replacedItemLabel = r.replacedItemLabel + updated.storedUnallocatedItemLabel = r.storedUnallocatedItemLabel + results[i] = updated + end + end + break + end + end + end + end + + return results, realBaseline +end + +-- Return the helper function for use by the UI +return buildDisplayedDisconnectedPassivePlans + +end -- return function(Class, helpers) diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua new file mode 100644 index 0000000000..7838ce41ee --- /dev/null +++ b/src/Classes/RadiusJewelData.lua @@ -0,0 +1,1113 @@ +-- Path of Building +-- +-- Module: Radius Jewel Data +-- Jewel type definitions, variants, scoring functions, and preview helpers +-- for the Radius Jewel Finder. +-- +local ipairs = ipairs +local pairs = pairs +local t_insert = table.insert +local t_sort = table.sort +local s_format = string.format + +local M = { } + +-- ───────────────────────────────────────────────────────────────────────────── +-- Color constants +-- ───────────────────────────────────────────────────────────────────────────── + +M.COL_UNIQUE = "^xAF6025" +M.COL_MOD = "^7" +M.COL_META = "^8" +M.COL_NEG = "^1" + +local COL_UNIQUE = M.COL_UNIQUE +local COL_MOD = M.COL_MOD +local COL_META = M.COL_META +local COL_NEG = M.COL_NEG + +-- ───────────────────────────────────────────────────────────────────────────── +-- Unique raw text lookup +-- ───────────────────────────────────────────────────────────────────────────── + +local uniqueRawTextByName +local uniqueRawTextByNameAndBase +local uniqueVariantRawTextCache = { } + +local function buildUniqueRawTextIndex() + local rawByName = { } + local rawByNameAndBase = { } + for _, uniqueList in pairs(data.uniques or { }) do + if type(uniqueList) == "table" then + for _, rawText in ipairs(uniqueList) do + if type(rawText) == "string" then + local name, baseName = rawText:match("^([^\n]+)\n([^\n]+)") + if name and not rawByName[name] then + rawByName[name] = rawText + end + if name and baseName then + rawByNameAndBase[name] = rawByNameAndBase[name] or { } + if not rawByNameAndBase[name][baseName] then + rawByNameAndBase[name][baseName] = rawText + end + end + end + end + end + end + return rawByName, rawByNameAndBase +end + +local function getUniqueRawText(name, fallbackRawText, baseName) + if not uniqueRawTextByName then + uniqueRawTextByName, uniqueRawTextByNameAndBase = buildUniqueRawTextIndex() + end + if baseName and uniqueRawTextByNameAndBase[name] and uniqueRawTextByNameAndBase[name][baseName] then + return uniqueRawTextByNameAndBase[name][baseName] + end + return uniqueRawTextByName[name] or fallbackRawText +end + +local function getUniqueVariantRawText(name, variantSelector, fallbackRawText, baseName) + if not variantSelector then + return getUniqueRawText(name, fallbackRawText, baseName) + end + local cacheKey = s_format("%s|%s|%s", name, baseName or "", tostring(variantSelector)) + if uniqueVariantRawTextCache[cacheKey] then + return uniqueVariantRawTextCache[cacheKey] + end + local rawText = getUniqueRawText(name, fallbackRawText, baseName) + if not rawText then + return nil + end + local item = new("Item"):Item("Rarity: Unique\n" .. rawText) + local selectedVariant + if type(variantSelector) == "number" then + selectedVariant = variantSelector + elseif item.variantList then + for idx, variantName in ipairs(item.variantList) do + if variantName == variantSelector then + selectedVariant = idx + break + end + end + end + if not selectedVariant then + return fallbackRawText or rawText + end + item.variant = selectedVariant + local builtRaw = item:BuildRaw():gsub("^Rarity: %w+\n", "") + uniqueVariantRawTextCache[cacheKey] = builtRaw + return builtRaw +end + +local function mustGetUniqueRawText(name, baseName) + local rawText = getUniqueRawText(name, nil, baseName) + assert(rawText, "Missing unique raw text: " .. name .. (baseName and (" [" .. baseName .. "]") or "")) + return rawText +end + +local function mustGetUniqueVariantRawText(name, variantSelector, baseName) + local rawText = getUniqueVariantRawText(name, variantSelector, nil, baseName) + assert(rawText, "Missing unique variant raw text: " .. name .. " [" .. tostring(variantSelector) .. "]" .. (baseName and (" [" .. baseName .. "]") or "")) + return rawText +end + +local function mustGetCurrentUniqueRawText(name, baseName) + return mustGetUniqueVariantRawText(name, "Current", baseName) +end + +-- Expose for compute module and tests +M.mustGetUniqueRawText = mustGetUniqueRawText + +-- ───────────────────────────────────────────────────────────────────────────── +-- Variant helpers +-- ───────────────────────────────────────────────────────────────────────────── + +local function buildVariantsFromUniqueItem(uniqueName, baseName) + local variants = { } + local baseRawText = mustGetUniqueRawText(uniqueName, baseName) + local item = new("Item"):Item("Rarity: Unique\n" .. baseRawText) + if item.variantList then + for idx, variantName in ipairs(item.variantList) do + local rawText = getUniqueVariantRawText(uniqueName, idx, nil, baseName) + if rawText then + t_insert(variants, { + name = variantName, + rawText = rawText, + }) + end + end + end + return variants +end + +M.buildVariantsFromUniqueItem = buildVariantsFromUniqueItem + +local function discoverFoulbornVariants(uniqueName, radiusIndexByLabel) + local variants = { } + local generated = data.uniques.generated + if not generated then return variants end + local escapedName = uniqueName:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") + for _, rawText in ipairs(generated) do + local comboIndex = rawText:match("^Foulborn " .. escapedName .. " (%d+)\n") + if comboIndex then + local radiusLabel = rawText:match("\nRadius: (%a+)") + local radiusIndex = radiusLabel and radiusIndexByLabel[radiusLabel] + t_insert(variants, { + name = "Foulborn " .. comboIndex, + rawText = rawText, + radiusIndex = radiusIndex, + isFoulborn = true, + comboIndex = tonumber(comboIndex), + }) + end + end + t_sort(variants, function(a, b) return a.comboIndex < b.comboIndex end) + return variants +end + +M.discoverFoulbornVariants = discoverFoulbornVariants + +-- ───────────────────────────────────────────────────────────────────────────── +-- Scoring functions +-- ───────────────────────────────────────────────────────────────────────────── + +local function scoreGainLoss(nodes, allocNodes, gainType, lossType) + local gained, lost = 0, 0 + for nodeId, node in pairs(nodes) do + if not node.ascendancyName and gainType and node.type == gainType and not allocNodes[nodeId] then + gained = gained + 1 + end + if not node.ascendancyName and lossType and node.type == lossType and allocNodes[nodeId] then + lost = lost + 1 + end + end + return gained - lost +end + +local function scoreAllocPassives(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if not node.ascendancyName and allocNodes[nodeId] and node.type ~= "Socket" and node.type ~= "ClassStart" + and node.type ~= "AscendClassStart" and node.type ~= "Mastery" then + s = s + 1 + end + end + return s +end + +M.scoreAllocPassives = scoreAllocPassives + +local function scoreUnallocPassives(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if not node.ascendancyName and not allocNodes[nodeId] and node.type ~= "Socket" and node.type ~= "ClassStart" + and node.type ~= "AscendClassStart" and node.type ~= "Mastery" then + s = s + 1 + end + end + return s +end + +local function scoreUnallocNotablesAndKeystones(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if not allocNodes[nodeId] and (node.type == "Notable" or node.type == "Keystone") then + s = s + 1 + end + end + return s +end + +local function getRadiusPassiveAttributeTotals(nodes, allocNodes, attribute) + local allocated = 0 + local unallocated = 0 + for nodeId, node in pairs(nodes) do + if not node.ascendancyName and node.type ~= "Socket" and node.type ~= "ClassStart" and node.type ~= "AscendClassStart" then + local amount = node.modList and node.modList:Sum("BASE", nil, attribute) or 0 + if amount ~= 0 then + if allocNodes[nodeId] then + allocated = allocated + amount + else + unallocated = unallocated + amount + end + end + end + end + return allocated, unallocated +end + +local function scoreRadiusAttributes(nodes, allocNodes, attribute, includeAllocated, includeUnallocated) + local allocated, unallocated = getRadiusPassiveAttributeTotals(nodes, allocNodes, attribute) + local score = 0 + if includeAllocated then + score = score + allocated + end + if includeUnallocated then + score = score + unallocated + end + return score +end + +local function makeRadiusAttributeDetail(attributeLabel, includeAllocated, includeUnallocated) + return function(nodes, allocNodes) + local allocated, unallocated = getRadiusPassiveAttributeTotals(nodes, allocNodes, attributeLabel) + if includeAllocated and includeUnallocated then + return s_format("%s alloc %d | %s unalloc %d", attributeLabel, allocated, attributeLabel, unallocated) + elseif includeAllocated then + return s_format("%s alloc %d", attributeLabel, allocated) + end + return s_format("%s unalloc %d", attributeLabel, unallocated) + end +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Foulborn finder fields +-- ───────────────────────────────────────────────────────────────────────────── +-- Foulborn variants are discovered first, then the finder adds local fields +-- such as scoreLabel, score, and keystoneOnly. + +local function addUnnaturalInstinctFoulbornFields(variant) + local typeMap = { Notable = "Notable", Small = "Normal" } + local rawText = variant.rawText + local gainLabel = rawText:match("Unallocated (%w+) Passive Skills") + local loseLabel = rawText:match("Allocated (%w+) Passive Skills.-grant nothing") + local gainType = gainLabel and typeMap[gainLabel] + local loseType = loseLabel and typeMap[loseLabel] + if gainType and loseType then + local gainShort = gainType == "Notable" and "notable" or "small" + local loseShort = loseType == "Notable" and "notable" or "small" + variant.scoreLabel = "unalloc " .. gainShort .. " - alloc " .. loseShort + variant.score = function(nodes, allocNodes) + return scoreGainLoss(nodes, allocNodes, gainType, loseType) + end + end +end + +local function addInspiredLearningFoulbornFields(variant) + local rawText = variant.rawText + if rawText:match("If no Notables Allocated") then + variant.scoreLabel = "no alloc notables" + variant.score = function(nodes, allocNodes) + for nodeId, node in pairs(nodes) do + if allocNodes[nodeId] and node.type == "Notable" then + return 0 + end + end + return 1 + end + elseif rawText:match("Small Passives Allocated") then + variant.scoreLabel = "alloc small passives" + variant.score = function(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if allocNodes[nodeId] and node.type == "Normal" then + s = s + 1 + end + end + return s + end + end +end + +local function addIntuitiveLeapFoulbornFields(variant) + local rawText = variant.rawText + if rawText:match("Massive Radius") then + variant.isMassiveRadius = true + end + if rawText:match("Keystone Passive Skills") then + variant.keystoneOnly = true + variant.scoreLabel = "unalloc keystones" + variant.score = function(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if not allocNodes[nodeId] and node.type == "Keystone" then + s = s + 1 + end + end + return s + end + end +end + +local function appendFoulbornVariants(jewelType, foulbornVariants) + if #foulbornVariants == 0 then return end + jewelType.variants = { + { name = "Normal", rawText = jewelType.rawText, radiusIndex = jewelType.radiusIndex }, + } + for _, foulbornVariant in ipairs(foulbornVariants) do + t_insert(jewelType.variants, foulbornVariant) + end +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Lazy variant lists +-- ───────────────────────────────────────────────────────────────────────────── + +local LIGHT_OF_MEANING_VARIANTS +local function getLightOfMeaningVariants() + if not LIGHT_OF_MEANING_VARIANTS then + LIGHT_OF_MEANING_VARIANTS = buildVariantsFromUniqueItem("The Light of Meaning") + end + return LIGHT_OF_MEANING_VARIANTS +end + +local function buildImpossibleEscapeVariants() + local variants = { } + for _, rawText in ipairs(data.uniques.generated or { }) do + if type(rawText) == "string" and rawText:match("^Impossible Escape\n") then + for line in rawText:gmatch("[^\n]+") do + local name = line:match("^Variant: (.+)$") + if name and name ~= "Everything (QoL Test Variant)" then + t_insert(variants, { + name = name, + dropdownLabel = name, + keystoneName = name, + rawText = mustGetUniqueVariantRawText("Impossible Escape", name), + scoreLabel = "unalloc notable/keystone near keystone", + }) + end + end + break + end + end + return variants +end + +local function makeTemperedVariant(name, rawText, attribute, includeAllocated, includeUnallocated) + local detailBuilder = makeRadiusAttributeDetail(attribute, includeAllocated, includeUnallocated) + return { + name = name, + rawText = rawText, + scoreLabel = includeAllocated and includeUnallocated and (attribute:lower() .. " alloc+unalloc") + or includeAllocated and (attribute:lower() .. " alloc") + or (attribute:lower() .. " unalloc"), + score = function(nodes, allocNodes) + return scoreRadiusAttributes(nodes, allocNodes, attribute, includeAllocated, includeUnallocated) + end, + detailBuilder = detailBuilder, + } +end + +local TEMPERED_TRANSCENDENT_VARIANTS +function M.getTemperedTranscendentVariants() + if not TEMPERED_TRANSCENDENT_VARIANTS then + TEMPERED_TRANSCENDENT_VARIANTS = { + makeTemperedVariant("Tempered Flesh", mustGetCurrentUniqueRawText("Tempered Flesh"), "Str", true, false), + makeTemperedVariant("Transcendent Flesh", mustGetCurrentUniqueRawText("Transcendent Flesh"), "Str", true, true), + makeTemperedVariant("Tempered Mind", mustGetCurrentUniqueRawText("Tempered Mind"), "Int", true, false), + makeTemperedVariant("Transcendent Mind", mustGetCurrentUniqueRawText("Transcendent Mind"), "Int", true, true), + makeTemperedVariant("Tempered Spirit", mustGetCurrentUniqueRawText("Tempered Spirit"), "Dex", true, false), + makeTemperedVariant("Transcendent Spirit", mustGetCurrentUniqueRawText("Transcendent Spirit"), "Dex", true, true), + } + end + return TEMPERED_TRANSCENDENT_VARIANTS +end + +local SPLIT_PERSONALITY_VARIANTS +function M.getSplitPersonalityVariants() + if not SPLIT_PERSONALITY_VARIANTS then + SPLIT_PERSONALITY_VARIANTS = buildVariantsFromUniqueItem("Split Personality") + end + return SPLIT_PERSONALITY_VARIANTS +end + +local IMPOSSIBLE_ESCAPE_VARIANTS +function M.getImpossibleEscapeVariants() + if not IMPOSSIBLE_ESCAPE_VARIANTS then + IMPOSSIBLE_ESCAPE_VARIANTS = buildImpossibleEscapeVariants() + end + return IMPOSSIBLE_ESCAPE_VARIANTS +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Dropdown / impact helpers +-- ───────────────────────────────────────────────────────────────────────────── + +function M.makeVariantDropdownEntry(variant) + local label = variant.dropdownLabel or variant.name + if label == variant.name then + return label + end + return { + label = label, + searchFilter = variant.name, + } +end + +function M.buildImpactStats() + local stats = { } + for _, stat in ipairs(data.powerStatList or { }) do + if stat.stat and not stat.combinedOffDef and not stat.itemField and stat.label ~= "Name" then + t_insert(stats, { + field = stat.stat, + label = stat.label, + selection = stat, + }) + end + end + return stats +end + +M.DISCONNECTED_PASSIVE_COMPUTE_METHODS = { + { id = "fast", label = "Fast" }, + { id = "simulated_greedy", label = "Simulated" }, +} + +M.OCCUPIED_SOCKET_OPTIONS = { + { id = "free", label = "Free only" }, + { id = "safe", label = "Safe occupied" }, + { id = "all", label = "All occupied" }, +} + +function M.findDisconnectedPassiveComputeMethod(methodId) + for _, method in ipairs(M.DISCONNECTED_PASSIVE_COMPUTE_METHODS) do + if method.id == methodId then + return method + end + end + return M.DISCONNECTED_PASSIVE_COMPUTE_METHODS[1] +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Jewel preview +-- ───────────────────────────────────────────────────────────────────────────── + +local function previewHeader(name, itemType, radius, extra) + local lines = { + { height = 20, [1] = COL_UNIQUE .. name }, + { height = 16, [1] = COL_META .. itemType }, + { height = 6, [1] = "" }, + } + if radius then + t_insert(lines, { height = 16, [1] = COL_META .. "Radius: " .. radius }) + end + if extra then + for _, e in ipairs(extra) do + t_insert(lines, { height = 16, [1] = COL_META .. e }) + end + end + t_insert(lines, { height = 6, [1] = "" }) + return lines +end + +local function previewFromRawText(rawText, displayName, extraPreviewMeta) + local item = new("Item"):Item("Rarity: Unique\n" .. rawText) + item:BuildModList() + + local itemName = displayName or item.title or "Unknown Jewel" + local itemType = item.baseName or "Jewel" + local radius = item.jewelRadiusLabel + local extra = { } + local mods = { } + + if item.limit then + t_insert(extra, "Limited to: " .. item.limit) + end + if item.source then + t_insert(extra, "Source: " .. item.source) + end + if item.league then + t_insert(extra, "League: " .. item.league) + end + for _, upgradePath in ipairs(item.upgradePaths or { }) do + t_insert(extra, "Upgrade: " .. upgradePath) + end + if rawText:match("(^|\n)Corrupted(\n|$)") then + t_insert(extra, "Corrupted") + end + + local function addActiveModLines(modLineList) + for _, modLine in ipairs(modLineList or { }) do + if item:CheckModLineVariant(modLine) then + for line in modLine.line:gmatch("[^\n]+") do + t_insert(mods, line) + end + end + end + end + + addActiveModLines(item.implicitModLines) + addActiveModLines(item.explicitModLines) + + local lines = previewHeader(itemName, itemType, radius, extra) + if extraPreviewMeta then + for _, meta in ipairs(extraPreviewMeta) do + t_insert(lines, { height = 16, [1] = COL_META .. meta }) + end + t_insert(lines, { height = 6, [1] = "" }) + end + for _, mod in ipairs(mods) do + local col = mod:match("^%-") and COL_NEG or COL_MOD + t_insert(lines, { height = 16, [1] = col .. mod }) + end + return lines +end + +local jewelPreviewFn -- set below; group preview functions read it from this outer local +jewelPreviewFn = { + ["The Light of Meaning"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText, "The Light of Meaning (" .. variant.name .. ")") + end + local lines = previewHeader("The Light of Meaning", "Prismatic Jewel", "Large", + { "Limited to: 1", "Source: King of The Mists" }) + for _, v in ipairs(getLightOfMeaningVariants()) do + t_insert(lines, { height = 14, [1] = COL_META .. " " .. v.name }) + end + return lines + end, + + ["Might of the Meek"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText) + end + local lines = previewHeader("Might of the Meek", "Crimson Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "50% increased Effect of non-Keystone" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Notable Passive Skills in Radius grant nothing" }) + return lines + end, + + ["Unnatural Instinct"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText) + end + local lines = previewHeader("Unnatural Instinct", "Viridian Jewel", "Small", + { "Limited to: 1" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Allocated Small Passive Skills in" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Radius grant nothing" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Grants all bonuses of Unallocated" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Small Passive Skills in Radius" }) + return lines + end, + + ["Inspired Learning"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText) + end + local lines = previewHeader("Inspired Learning", "Crimson Jewel", "Small") + t_insert(lines, { height = 16, [1] = COL_MOD .. "With 4 Notables Allocated in Radius," }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "When you Kill a Rare monster, you gain" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "1 of its Modifiers for 20 seconds" }) + return lines + end, + + ["Anatomical Knowledge"] = function() + local lines = previewHeader("Anatomical Knowledge", "Cobalt Jewel", "Large", + { "Source: No longer obtainable" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "(6-8)% increased maximum Life" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Adds 1 to Maximum Life per 3" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence Allocated in Radius" }) + return lines + end, + + ["Lioneye's Fall"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText) + end + local lines = previewHeader("Lioneye's Fall", "Viridian Jewel", "Medium") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Melee and Melee Weapon Type modifiers" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "in Radius are Transformed to Bow Modifiers" }) + return lines + end, + + ["Intuitive Leap"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText) + end + local lines = previewHeader("Intuitive Leap", "Viridian Jewel", "Small") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives in Radius can be Allocated" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "without being connected to your tree" }) + return lines + end, + + ["Tempered & Transcendent"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText, variant.name) + end + local lines = previewHeader("Tempered & Transcendent", "Unique Jewel", "Medium") + t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Flesh / Transcendent Flesh" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Mind / Transcendent Mind" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Spirit / Transcendent Spirit" }) + return lines + end, + + ["Split Personality"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText, "Split Personality (" .. variant.name .. ")") + end + local lines = previewHeader("Split Personality", "Crimson Jewel", nil, + { "Limited to: 2", "Source: Drops from the Simulacrum Encounter" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Socket effect scales with distance to class start" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Variants: Strength, Dexterity, Intelligence, Life" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Mana, Energy Shield, Armour, Evasion, Accuracy" }) + return lines + end, + + ["Impossible Escape"] = function(variant) + if variant and variant.rawText then + return previewFromRawText(variant.rawText, "Impossible Escape (" .. variant.name .. ")") + end + local lines = previewHeader("Impossible Escape", "Viridian Jewel", "Small", + { "Limited to: 1", "Source: Drops from The Maven (Uber)" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in radius of the chosen" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Keystone can be allocated without connection" }) + return lines + end, + + ["Energy From Within"] = function() + local lines = previewHeader("Energy From Within", "Cobalt Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "3% increased maximum Energy Shield" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Life mods in Radius apply to Energy Shield" }) + return lines + end, + + ["Healthy Mind"] = function() + local lines = previewHeader("Healthy Mind", "Cobalt Jewel", "Large", + { "Limited to: 1" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "15% increased maximum Mana" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Life mods in Radius apply to Mana at 200%" }) + return lines + end, + + ["Energised Armour"] = function() + local lines = previewHeader("Energised Armour", "Crimson Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "15% increased Armour" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "ES mods in Radius apply to Armour at 200%" }) + return lines + end, + + ["Brute Force Solution"] = function() + local lines = previewHeader("Brute Force Solution", "Cobalt Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Intelligence" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Strength from Passives -> Intelligence" }) + return lines + end, + + ["Careful Planning"] = function() + local lines = previewHeader("Careful Planning", "Viridian Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Dexterity" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence from Passives -> Dexterity" }) + return lines + end, + + ["Efficient Training"] = function() + local lines = previewHeader("Efficient Training", "Crimson Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Strength" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence from Passives -> Strength" }) + return lines + end, + + ["Fertile Mind"] = function() + local lines = previewHeader("Fertile Mind", "Cobalt Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Intelligence" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Dexterity from Passives -> Intelligence" }) + return lines + end, + + ["Fluid Motion"] = function() + local lines = previewHeader("Fluid Motion", "Viridian Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Dexterity" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Strength from Passives -> Dexterity" }) + return lines + end, + + ["Inertia"] = function() + local lines = previewHeader("Inertia", "Crimson Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Strength" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Dexterity from Passives -> Strength" }) + return lines + end, + + ["Combat Focus (Crimson)"] = function() + local lines = previewHeader("Combat Focus", "Crimson Jewel", "Medium", + { "Limited to: 2", "Source: Vendor Recipe" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Cold" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Str+Int in Radius" }) + return lines + end, + + ["Combat Focus (Cobalt)"] = function() + local lines = previewHeader("Combat Focus", "Cobalt Jewel", "Medium", + { "Limited to: 2", "Source: Vendor Recipe" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Fire" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Int+Dex in Radius" }) + return lines + end, + + ["Combat Focus (Viridian)"] = function() + local lines = previewHeader("Combat Focus", "Viridian Jewel", "Medium", + { "Limited to: 2", "Source: Vendor Recipe" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Lightning" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Dex+Str in Radius" }) + return lines + end, + + ["Attribute Conversion"] = function(variant) + if variant and jewelPreviewFn[variant.name] then + return jewelPreviewFn[variant.name]() + end + local lines = previewHeader("Attribute Conversion", "Corrupted Jewel", "Large") + t_insert(lines, { height = 14, [1] = COL_META .. "Brute Force Solution: Str -> Int" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Careful Planning: Int -> Dex" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Efficient Training: Int -> Str" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Fertile Mind: Dex -> Int" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Fluid Motion: Str -> Dex" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Inertia: Dex -> Str" }) + return lines + end, + + ["Stat Conversion"] = function(variant) + if variant and jewelPreviewFn[variant.name] then + return jewelPreviewFn[variant.name]() + end + local lines = previewHeader("Stat Conversion", "Corrupted Jewel", "Large") + t_insert(lines, { height = 14, [1] = COL_META .. "Energy From Within: Life -> Energy Shield" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Healthy Mind: Life -> Mana (200%)" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Energised Armour: ES -> Armour (200%)" }) + return lines + end, + + ["Combat Focus"] = function(variant) + if variant and jewelPreviewFn[variant.name] then + return jewelPreviewFn[variant.name]() + end + local lines = previewHeader("Combat Focus", "Jewel", "Medium", + { "Limited to: 2", "Source: Vendor Recipe" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Crimson: lose Cold (Str+Int >= 40)" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Cobalt: lose Fire (Int+Dex >= 40)" }) + t_insert(lines, { height = 14, [1] = COL_META .. "Viridian: lose Lightning (Dex+Str >= 40)" }) + return lines + end, + + ["Dreams & Nightmares"] = function(variant) + if variant and variant.rawText then + local extraPreviewMeta = nil + if variant.family then + extraPreviewMeta = { "Family: " .. variant.family:gsub("^The ", "") } + end + return previewFromRawText(variant.rawText, variant.name, extraPreviewMeta) + end + local lines = previewHeader("Dreams & Nightmares", "Unique Jewel", "Large") + t_insert(lines, { height = 14, [1] = COL_META .. "The Red Dream: Fire Res -> Endurance on Kill" }) + t_insert(lines, { height = 14, [1] = COL_META .. "The Red Nightmare: Fire Res -> Block" }) + t_insert(lines, { height = 14, [1] = COL_META .. "The Green Dream: Cold Res -> Frenzy on Kill" }) + t_insert(lines, { height = 14, [1] = COL_META .. "The Green Nightmare: Cold Res -> Suppress" }) + t_insert(lines, { height = 14, [1] = COL_META .. "The Blue Dream: Lightning Res -> Power on Kill" }) + t_insert(lines, { height = 14, [1] = COL_META .. "The Blue Nightmare: Lightning Res -> Spell Block" }) + return lines + end, + + ["The Red Dream"] = function() + local lines = previewHeader("The Red Dream", "Crimson Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Fire/All Res in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Endurance Charge on Kill" }) + return lines + end, + + ["The Red Nightmare"] = function() + local lines = previewHeader("The Red Nightmare", "Crimson Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Fire/All Res in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Chance to Block at 50%" }) + return lines + end, + + ["The Green Dream"] = function() + local lines = previewHeader("The Green Dream", "Viridian Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Cold/All Res in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Frenzy Charge on Kill" }) + return lines + end, + + ["The Green Nightmare"] = function() + local lines = previewHeader("The Green Nightmare", "Viridian Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Cold/All Res in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Chance to Suppress at 70%" }) + return lines + end, + + ["The Blue Dream"] = function() + local lines = previewHeader("The Blue Dream", "Cobalt Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Lightning/All Res in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Power Charge on Kill" }) + return lines + end, + + ["The Blue Nightmare"] = function() + local lines = previewHeader("The Blue Nightmare", "Cobalt Jewel", "Large") + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Lightning/All Res in Radius" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Spell Block at 50%" }) + return lines + end, + + ["Thread of Hope"] = function(ringName) + local ring = ringName or "?" + local lines = previewHeader("Thread of Hope", "Crimson Jewel", "Variable", + { "Source: Drops from Sirus, Awakener of Worlds" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Only affects Passives in " .. ring .. " Ring" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in Radius can be Allocated" }) + t_insert(lines, { height = 16, [1] = COL_MOD .. "without being connected to your tree" }) + t_insert(lines, { height = 6, [1] = "" }) + t_insert(lines, { height = 16, [1] = COL_NEG .. "-(20-10)% to all Elemental Resistances" }) + return lines + end, +} + +M.jewelPreviewFn = jewelPreviewFn + +-- ───────────────────────────────────────────────────────────────────────────── +-- Jewel type definitions +-- ───────────────────────────────────────────────────────────────────────────── + +function M.buildJewelTypes(radiusIndexByLabel) + local mightOfTheMeek = { + name = "Might of the Meek", + radiusIndex = radiusIndexByLabel["Large"], + scoreLabel = "alloc small passives", + hasCompute = true, + rawText = mustGetUniqueRawText("Might of the Meek"), + score = function(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if allocNodes[nodeId] and node.type == "Normal" then + s = s + 1 + end + end + return s + end, + } + appendFoulbornVariants(mightOfTheMeek, discoverFoulbornVariants("Might of the Meek", radiusIndexByLabel)) + + local inspiredLearning = { + name = "Inspired Learning", + hasCompute = true, + radiusIndex = radiusIndexByLabel["Small"], + scoreLabel = "alloc notables", + rawText = mustGetUniqueRawText("Inspired Learning"), + score = function(nodes, allocNodes) + local s = 0 + for nodeId, node in pairs(nodes) do + if allocNodes[nodeId] and node.type == "Notable" then + s = s + 1 + end + end + return s + end, + } + do + local foulbornVariants = discoverFoulbornVariants("Inspired Learning", radiusIndexByLabel) + for _, variant in ipairs(foulbornVariants) do addInspiredLearningFoulbornFields(variant) end + appendFoulbornVariants(inspiredLearning, foulbornVariants) + end + + local unnaturalInstinct = { + name = "Unnatural Instinct", + radiusIndex = radiusIndexByLabel["Small"], + scoreLabel = "unalloc small - alloc small", + hasCompute = true, + rawText = mustGetUniqueRawText("Unnatural Instinct"), + score = function(nodes, allocNodes) + local gained, lost = 0, 0 + for nodeId, node in pairs(nodes) do + if node.type == "Normal" then + if allocNodes[nodeId] then lost = lost + 1 + else gained = gained + 1 end + end + end + return gained - lost + end, + } + do + local foulbornVariants = discoverFoulbornVariants("Unnatural Instinct", radiusIndexByLabel) + for _, variant in ipairs(foulbornVariants) do addUnnaturalInstinctFoulbornFields(variant) end + appendFoulbornVariants(unnaturalInstinct, foulbornVariants) + end + + local lioneyesFall = { + name = "Lioneye's Fall", + radiusIndex = radiusIndexByLabel["Medium"], + scoreLabel = "alloc passives", + hasCompute = true, + rawText = mustGetUniqueRawText("Lioneye's Fall"), + score = scoreAllocPassives, + } + appendFoulbornVariants(lioneyesFall, discoverFoulbornVariants("Lioneye's Fall", radiusIndexByLabel)) + + local intuitiveLeap = { + name = "Intuitive Leap", + radiusIndex = radiusIndexByLabel["Small"], + scoreLabel = "unalloc passives", + hasCompute = true, + computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, + rawText = mustGetUniqueRawText("Intuitive Leap"), + score = function(nodes, allocNodes) + return scoreUnallocPassives(nodes, allocNodes) + end, + } + do + local foulbornVariants = discoverFoulbornVariants("Intuitive Leap", radiusIndexByLabel) + for _, variant in ipairs(foulbornVariants) do addIntuitiveLeapFoulbornFields(variant) end + appendFoulbornVariants(intuitiveLeap, foulbornVariants) + end + + local dreamsNightmaresFamilies = { + { name = "The Red Dream", baseName = "Crimson Jewel" }, + { name = "The Red Nightmare", baseName = "Crimson Jewel" }, + { name = "The Green Dream", baseName = "Viridian Jewel" }, + { name = "The Green Nightmare", baseName = "Viridian Jewel" }, + { name = "The Blue Dream", baseName = "Cobalt Jewel" }, + { name = "The Blue Nightmare", baseName = "Cobalt Jewel" }, + } + local dreamsVariants = { } + for _, familyInfo in ipairs(dreamsNightmaresFamilies) do + t_insert(dreamsVariants, { + name = familyInfo.name, + family = familyInfo.name, + rawText = mustGetCurrentUniqueRawText(familyInfo.name), + }) + local foulbornVariants = discoverFoulbornVariants(familyInfo.name, radiusIndexByLabel) + for _, variant in ipairs(foulbornVariants) do + variant.family = familyInfo.name + variant.name = familyInfo.name .. " (" .. variant.name .. ")" + t_insert(dreamsVariants, variant) + end + end + + local jewelTypes = { } + t_insert(jewelTypes, { + name = "The Light of Meaning", + limit = 1, + radiusIndex = radiusIndexByLabel["Large"], + scoreLabel = "alloc passives", + hasCompute = true, + score = scoreAllocPassives, + variants = getLightOfMeaningVariants(), + }) + t_insert(jewelTypes, mightOfTheMeek) + t_insert(jewelTypes, unnaturalInstinct) + t_insert(jewelTypes, inspiredLearning) + t_insert(jewelTypes, { + name = "Anatomical Knowledge", + radiusIndex = radiusIndexByLabel["Large"], + scoreLabel = "alloc passives", + hasCompute = true, + isLegacy = true, + rawText = mustGetUniqueRawText("Anatomical Knowledge"), + score = scoreAllocPassives, + }) + t_insert(jewelTypes, { + name = "Tempered & Transcendent", + radiusIndex = radiusIndexByLabel["Medium"], + scoreLabel = "attr in radius", + hasCompute = true, + score = function(nodes, allocNodes) + return scoreRadiusAttributes(nodes, allocNodes, "Str", true, false) + end, + variants = M.getTemperedTranscendentVariants(), + }) + t_insert(jewelTypes, lioneyesFall) + t_insert(jewelTypes, intuitiveLeap) + t_insert(jewelTypes, { + name = "Impossible Escape", + isImpossibleEscape = true, + isSocketIndependent = true, + scoreLabel = "unalloc notable/keystone near keystone", + hasCompute = true, + computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, + score = scoreUnallocNotablesAndKeystones, + variants = M.getImpossibleEscapeVariants(), + }) + t_insert(jewelTypes, { + name = "Split Personality", + isSplitPersonality = true, + scoreLabel = "dist to start", + hasCompute = true, + score = function() + return 0 + end, + variants = M.getSplitPersonalityVariants(), + }) + t_insert(jewelTypes, { + name = "Stat Conversion", + radiusIndex = radiusIndexByLabel["Large"], + scoreLabel = "alloc passives", + hasCompute = true, + score = scoreAllocPassives, + variants = { + { name = "Energy From Within", rawText = mustGetUniqueRawText("Energy From Within") }, + { name = "Healthy Mind", rawText = mustGetUniqueRawText("Healthy Mind") }, + { name = "Energised Armour", rawText = mustGetUniqueRawText("Energised Armour") }, + }, + }) + t_insert(jewelTypes, { + name = "Attribute Conversion", + radiusIndex = radiusIndexByLabel["Large"], + scoreLabel = "alloc passives", + hasCompute = true, + score = scoreAllocPassives, + variants = { + { name = "Brute Force Solution", rawText = mustGetUniqueRawText("Brute Force Solution") }, + { name = "Careful Planning", rawText = mustGetUniqueRawText("Careful Planning") }, + { name = "Efficient Training", rawText = mustGetUniqueRawText("Efficient Training") }, + { name = "Fertile Mind", rawText = mustGetUniqueRawText("Fertile Mind") }, + { name = "Fluid Motion", rawText = mustGetUniqueRawText("Fluid Motion") }, + { name = "Inertia", rawText = mustGetUniqueRawText("Inertia") }, + }, + }) + t_insert(jewelTypes, { + name = "Combat Focus", + radiusIndex = radiusIndexByLabel["Medium"], + scoreLabel = "alloc passives", + hasCompute = true, + score = scoreAllocPassives, + variants = { + { name = "Combat Focus (Crimson)", rawText = mustGetUniqueRawText("Combat Focus", "Crimson Jewel") }, + { name = "Combat Focus (Cobalt)", rawText = mustGetUniqueRawText("Combat Focus", "Cobalt Jewel") }, + { name = "Combat Focus (Viridian)", rawText = mustGetUniqueRawText("Combat Focus", "Viridian Jewel") }, + }, + }) + t_insert(jewelTypes, { + name = "Dreams & Nightmares", + radiusIndex = radiusIndexByLabel["Large"], + scoreLabel = "alloc passives", + hasCompute = true, + score = scoreAllocPassives, + variants = dreamsVariants, + }) + t_insert(jewelTypes, { + name = "Thread of Hope", + isThread = true, + scoreLabel = "unalloc notable/keystone in ring", + hasCompute = true, + computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, + rawText = nil, + score = scoreUnallocNotablesAndKeystones, + }) + return jewelTypes +end + +function M.jewelTypeSortOrder(jt) + if jt.name == "The Light of Meaning" then return 10 end + if jt.name == "Might of the Meek" then return 20 end + if jt.name == "Unnatural Instinct" then return 30 end + if jt.name == "Inspired Learning" then return 40 end + if jt.name == "Anatomical Knowledge" then return 50 end + if jt.name == "Tempered & Transcendent" then return 55 end + if jt.name == "Lioneye's Fall" then return 60 end + if jt.name == "Intuitive Leap" then return 70 end + if jt.isImpossibleEscape then return 75 end + if jt.isSplitPersonality then return 80 end + if jt.name == "Stat Conversion" then return 90 end + if jt.name == "Attribute Conversion" then return 100 end + if jt.name == "Combat Focus" then return 110 end + if jt.name == "Dreams & Nightmares" then return 120 end + if jt.isThread then return 130 end + return 1000 +end + +return M diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua new file mode 100644 index 0000000000..e9537a6959 --- /dev/null +++ b/src/Classes/RadiusJewelFinder.lua @@ -0,0 +1,2630 @@ +-- Path of Building +-- +-- Class: Radius Jewel Finder +-- Popup that scores passive tree sockets for radius unique jewels. +-- Supports: The Light of Meaning, Might of the Meek, Unnatural Instinct, +-- Inspired Learning, Anatomical Knowledge, Thread of Hope, Lioneye's Fall, +-- Intuitive Leap, Tempered Flesh, Tempered Mind, Tempered Spirit, +-- Transcendent Flesh, Transcendent Mind, Transcendent Spirit, +-- Split Personality, Impossible Escape, +-- Energy From Within, Healthy Mind, Energised Armour, +-- Brute Force Solution, Careful Planning, Efficient Training, +-- Fertile Mind, Fluid Motion, Inertia, +-- Combat Focus (Crimson/Cobalt/Viridian), +-- The Red Dream, The Red Nightmare, The Green Dream, The Green Nightmare, +-- The Blue Dream, The Blue Nightmare. +-- +local ipairs = ipairs +local pairs = pairs +local t_insert = table.insert +local t_sort = table.sort +local t_concat = table.concat +local s_format = string.format +local m_huge = math.huge +local m_abs = math.abs + +local RadiusJewelData = LoadModule("Classes/RadiusJewelData") +local COL_META = RadiusJewelData.COL_META + +-- Small output snapshot for stat-comparison tooltips. +-- Copies only scalar fields and the small tables needed by +-- AddStatComparesToTooltip / AddRequirementWarningsToTooltip, +-- skipping heavy sub-tables (SkillDPS, env, modDB, etc.) +-- that would otherwise cause multi-GB memory usage. +local function extractTooltipStats(output) + if not output then return nil end + local out = {} + for k, v in pairs(output) do + local t = type(v) + if t == "number" or t == "string" or t == "boolean" then + out[k] = v + end + end + -- Requirement fail lists (small tables with source references) + for _, key in ipairs({"ReqStrFailList", "ReqDexFailList", "ReqIntFailList", "ReqOmniFailList", + "ReqStrItem", "ReqDexItem", "ReqIntItem", "ReqOmniItem"}) do + if output[key] then + out[key] = output[key] + end + end + -- Copy minion stats with the same scalar-only treatment. + if output.Minion then + out.Minion = extractTooltipStats(output.Minion) + end + return out +end + +local function formatSignedValue(value) + local sign = value >= 0 and "+" or "" + local col = value > 0 and "^2" or (value < 0 and "^1" or "^8") + return s_format("%s%s%.1f", col, sign, value) +end + +local function formatSignedPercent(value) + local sign = value >= 0 and "+" or "" + local col = value > 0 and "^2" or (value < 0 and "^1" or "^8") + return s_format("%s%s%.1f%%", col, sign, value) +end + +local function formatPerPointDisplay(value, points) + if points == 0 then + return value > 0 and "^2Free" or (value < 0 and "^1Free" or "^8Free") + end + return formatSignedPercent(value) +end + +local ACTION_COLORS = { + new = "^2", + move = "^x33AAFF", + moveReplace = "^xBB88FF", + replace = "^xFFAA33", + keep = "^8", +} +local function colorSocketLabel(row) + return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel +end + +local RESULT_DETAIL_COLUMN_BY_MODE = { + computeSocket = 6, + computeSocketAll = 7, + find = 5, + findThread = 6, +} +local RESULT_SOCKET_COLUMN_BY_MODE = { + computeSocket = 1, + computeSocketAll = 2, + find = 1, + findThread = 1, +} +local RESULT_STAT_COLUMNS_BY_MODE = { + computeSocket = { [3] = true, [4] = true, [5] = true }, + computeSocketAll = { [4] = true, [5] = true, [6] = true }, +} +local RESULT_ITEM_COLUMNS_BY_MODE = { + computeSocket = { [6] = true }, + computeSocketAll = { [7] = true }, + find = { [5] = true }, + findThread = { [6] = true }, +} + +---@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 = { + { width = rect[3] - 22, label = "" }, + }, + computeSocket = { + { width = 170, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 75, label = "Gain", sortable = true }, + { width = 60, label = "%", sortable = true }, + { width = 65, label = "%/Pt", sortable = true }, + { width = 150, label = "Detail", sortable = true }, + }, + computeSocketAll = { + { width = 120, label = "Jewel", sortable = true }, + { width = 130, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 75, label = "Gain", sortable = true }, + { width = 60, label = "%", sortable = true }, + { width = 65, label = "%/Pt", sortable = true }, + { width = 70, label = "Detail", sortable = true }, + }, + find = { + { width = 170, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 60, label = "Score", sortable = true }, + { width = 70, label = "/Pt", sortable = true }, + { width = 220, label = "Detail", sortable = true }, + }, + findThread = { + { width = 170, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 60, label = "Score", sortable = true }, + { width = 70, label = "/Pt", sortable = true }, + { width = 90, label = "Ring", sortable = true }, + { width = 130, label = "Detail", sortable = true }, + }, + } + self.defaultSortByMode = { + computeSocket = 5, + computeSocketAll = 6, + find = 4, + findThread = 4, + } + self.resultTooltip = new("Tooltip"):Tooltip() + self.itemTooltip = new("Tooltip"):Tooltip() + return self +end + +---@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() + 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 not hoverLine.nodeId or main.popups[2] then + return + end + local node = self.build.spec.nodes[hoverLine.nodeId] or self.build.spec.tree.nodes[hoverLine.nodeId] + if not node then + return + end + + local function clampRectPosition(x, y, width, height) + x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) + y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) + return x, y + end + local function rectsOverlap(aX, aY, aW, aH, bX, bY, bW, bH) + return aX < bX + bW and aX + aW > bX and aY < bY + bH and aY + aH > bY + end + local function placeTooltip(ttW, ttH, cursorX, cursorY, blockedRects) + local candidates = { + { x = cursorX + 20, y = cursorY + 20 }, + { x = cursorX - ttW - 20, y = cursorY + 20 }, + { x = cursorX + 20, y = cursorY - ttH - 20 }, + { x = cursorX - ttW - 20, y = cursorY - ttH - 20 }, + } + for _, candidate in ipairs(candidates) do + local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH) + local overlaps = false + for _, blockedRect in ipairs(blockedRects or { }) do + if rectsOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then + overlaps = true + break + end + end + if not overlaps then + return ttX, ttY + end + end + return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) + end + + local cursorX, cursorY = GetCursorPos() + local viewerRect + SetDrawLayer(nil, 15) + local viewerX = cursorX + 20 + local viewerY = cursorY - 150 + if viewerX + 304 > viewPort.x + viewPort.width then viewerX = cursorX - 324 end + if viewerY < viewPort.y then viewerY = viewPort.y elseif viewerY + 304 > viewPort.y + viewPort.height then viewerY = viewPort.y + viewPort.height - 304 end + viewerRect = { x = viewerX, y = viewerY, width = 304, height = 304 } + + SetDrawColor(1, 1, 1) + DrawImage(nil, viewerX, viewerY, 304, 304) + self.socketViewer.zoom = 5 + local scale = self.build.spec.tree.size / 1500 + self.socketViewer.zoomX = -node.x / scale + self.socketViewer.zoomY = -node.y / scale + self.socketViewer.searchStrResults[hoverLine.nodeId] = true + SetViewport(viewerX + 2, viewerY + 2, 300, 300) + self.socketViewer:Draw(self.build, { x = 0, y = 0, width = 300, height = 300 }, { }) + self.socketViewer.searchStrResults[hoverLine.nodeId] = nil + SetDrawLayer(nil, 30) + SetDrawColor(1, 1, 1, 0.2) + DrawImage(nil, 149, 0, 2, 300) + DrawImage(nil, 0, 149, 300, 2) + SetViewport() + + SetDrawLayer(nil, 100) + self.nodeTooltip:Clear(true) + local prevShowStatDifferences = self.socketViewer.showStatDifferences + self.socketViewer.showStatDifferences = true + self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build) + self.socketViewer.showStatDifferences = prevShowStatDifferences + local ttW, ttH = self.nodeTooltip:GetSize() + local ttX, ttY = placeTooltip(ttW, ttH, cursorX, cursorY, { viewerRect }) + self.nodeTooltip:Draw(ttX, ttY, nil, nil, viewPort) + SetDrawLayer(nil, 0) +end + +function RadiusJewelResultsListClass:SetMode(mode, list, defaultText) + self.mode = mode or "message" + self.list = list or { } + self.defaultText = defaultText or "" + self.colList = self.columnsByMode[self.mode] or self.columnsByMode.message + self.colLabels = self.mode ~= "message" and #self.list > 0 + local defaultSort = self.defaultSortByMode[self.mode] + if defaultSort and #self.list > 0 then + self:ReSort(defaultSort) + end + if self.mode ~= "message" and #self.list > 0 then + self:SelectIndex(1) + else + self.selIndex = nil + self.selValue = nil + if self.OnSelect then + self:OnSelect(nil, nil) + end + end +end + +function RadiusJewelResultsListClass:GetHoverInfo(hoverColumn, hoverData) + local detailColumn = hoverColumn and RESULT_DETAIL_COLUMN_BY_MODE[self.mode] == hoverColumn + local socketColumn = hoverColumn and RESULT_SOCKET_COLUMN_BY_MODE[self.mode] == hoverColumn + local showViewer = socketColumn or (detailColumn and hoverData and hoverData.detailNodeId) + local showStatTooltip = hoverData and hoverData.baseOutput and hoverData.compareOutput + and hoverColumn and RESULT_STAT_COLUMNS_BY_MODE[self.mode] and RESULT_STAT_COLUMNS_BY_MODE[self.mode][hoverColumn] + local showItemTooltip = hoverData and hoverData.itemTooltipLines + and hoverColumn and RESULT_ITEM_COLUMNS_BY_MODE[self.mode] and RESULT_ITEM_COLUMNS_BY_MODE[self.mode][hoverColumn] + local hoverNodeId = hoverData and hoverData.socketId or nil + if hoverData and hoverData.detailNodeId and detailColumn then + hoverNodeId = hoverData.detailNodeId + end + return { + detailColumn = detailColumn, + socketColumn = socketColumn, + showViewer = showViewer, + showStatTooltip = showStatTooltip, + showItemTooltip = showItemTooltip, + hoverNodeId = hoverNodeId, + } +end + +function RadiusJewelResultsListClass:ReSort(colIndex) + if self.mode == "computeSocket" then + if colIndex == 1 then + t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) + elseif colIndex == 2 then + t_sort(self.list, function(a, b) return a.points < b.points end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) return a.delta > b.delta end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) return a.pct > b.pct end) + elseif colIndex == 5 then + t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) + elseif colIndex == 6 then + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + elseif self.mode == "computeSocketAll" then + if colIndex == 1 then + t_sort(self.list, function(a, b) return a.jewelName < b.jewelName end) + elseif colIndex == 2 then + t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) return a.points < b.points end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) return a.delta > b.delta end) + elseif colIndex == 5 then + t_sort(self.list, function(a, b) return a.pct > b.pct end) + elseif colIndex == 6 then + t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) + elseif colIndex == 7 then + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + elseif self.mode == "find" or self.mode == "findThread" then + if colIndex == 1 then + t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) + elseif colIndex == 2 then + t_sort(self.list, function(a, b) return a.points < b.points end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) return a.score > b.score end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) return a.scorePerPointSort > b.scorePerPointSort end) + elseif colIndex == 5 then + if self.mode == "findThread" then + t_sort(self.list, function(a, b) return a.variantLabel < b.variantLabel end) + else + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + elseif colIndex == 6 and self.mode == "findThread" then + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + end +end + +function RadiusJewelResultsListClass:GetRowValue(column, index, row) + if self.mode == "message" then + return column == 1 and row.text or "" + elseif self.mode == "computeSocket" then + return column == 1 and colorSocketLabel(row) + or column == 2 and tostring(row.points) + or column == 3 and formatSignedValue(row.delta) + or column == 4 and formatSignedPercent(row.pct) + or column == 5 and formatPerPointDisplay(row.pctPerPoint, row.points) + or column == 6 and row.detailText + or "" + elseif self.mode == "computeSocketAll" then + return column == 1 and row.jewelName + or column == 2 and colorSocketLabel(row) + or column == 3 and tostring(row.points) + or column == 4 and formatSignedValue(row.delta) + or column == 5 and formatSignedPercent(row.pct) + or column == 6 and formatPerPointDisplay(row.pctPerPoint, row.points) + or column == 7 and row.detailText + or "" + elseif self.mode == "find" then + return column == 1 and colorSocketLabel(row) + or column == 2 and tostring(row.points) + or column == 3 and s_format("^7%d", row.score) + or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) + or column == 5 and row.detailText + or "" + elseif self.mode == "findThread" then + return column == 1 and colorSocketLabel(row) + or column == 2 and tostring(row.points) + or column == 3 and s_format("^7%d", row.score) + or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) + or column == 5 and row.variantLabel + or column == 6 and row.detailText + or "" + end + return "" +end + +function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) + self.ListControl.Draw(self, viewPort, true) + if self.suppressTooltipFunc and self.suppressTooltipFunc() then + return + end + local hoverData = self.hoverValue + if not hoverData or main.popups[2] then + return + end + + local function clampRectPosition(x, y, width, height) + x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) + y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) + return x, y + end + local function rectsOverlap(aX, aY, aW, aH, bX, bY, bW, bH) + return aX < bX + bW and aX + aW > bX and aY < bY + bH and aY + aH > bY + end + local function placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRects) + local candidates = { + { x = cursorX + 20, y = cursorY + 20 }, + { x = cursorX - ttW - 20, y = cursorY + 20 }, + { x = cursorX + 20, y = cursorY - ttH - 20 }, + { x = cursorX - ttW - 20, y = cursorY - ttH - 20 }, + } + local primaryBlockedRect = blockedRects and blockedRects[1] or nil + if primaryBlockedRect then + t_insert(candidates, 1, { x = primaryBlockedRect.x - ttW - 12, y = cursorY + 20 }) + t_insert(candidates, 2, { x = primaryBlockedRect.x + primaryBlockedRect.width + 12, y = cursorY + 20 }) + t_insert(candidates, 3, { x = primaryBlockedRect.x, y = primaryBlockedRect.y - ttH - 12 }) + t_insert(candidates, 4, { x = primaryBlockedRect.x, y = primaryBlockedRect.y + primaryBlockedRect.height + 12 }) + end + for _, candidate in ipairs(candidates) do + local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH) + local overlapsBlockedRect = false + for _, blockedRect in ipairs(blockedRects or { }) do + if rectsOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then + overlapsBlockedRect = true + break + end + end + if not overlapsBlockedRect then + return ttX, ttY + end + end + return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) + end + + local cursorX, cursorY = GetCursorPos() + local x, y = self:GetPos() + local relX = cursorX - (x + 2) + local hoverColumn + if hoverData then + for columnIndex, column in ipairs(self.colList) do + local colOffset = column._offset or 0 + local colWidth = column._width or 0 + if relX >= colOffset and relX < colOffset + colWidth then + hoverColumn = columnIndex + break + end + end + end + local hoverInfo = self:GetHoverInfo(hoverColumn, hoverData) + local viewerRect + if hoverInfo.showViewer and hoverInfo.hoverNodeId then + local node = self.build.spec.nodes[hoverInfo.hoverNodeId] or self.build.spec.tree.nodes[hoverInfo.hoverNodeId] + if node then + SetDrawLayer(nil, 15) + local viewerX = cursorX + 20 + local viewerY = cursorY - 150 + if viewerX + 304 > viewPort.x + viewPort.width then viewerX = cursorX - 324 end + if viewerY < viewPort.y then viewerY = viewPort.y elseif viewerY + 304 > viewPort.y + viewPort.height then viewerY = viewPort.y + viewPort.height - 304 end + viewerRect = { x = viewerX, y = viewerY, width = 304, height = 304 } + + SetDrawColor(1, 1, 1) + DrawImage(nil, viewerX, viewerY, 304, 304) + self.socketViewer.zoom = 5 + local scale = self.build.spec.tree.size / 1500 + self.socketViewer.zoomX = -node.x / scale + self.socketViewer.zoomY = -node.y / scale + self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = true + SetViewport(viewerX + 2, viewerY + 2, 300, 300) + self.socketViewer:Draw(self.build, { x = 0, y = 0, width = 300, height = 300 }, { }) + self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = nil + SetDrawLayer(nil, 30) + SetDrawColor(1, 1, 1, 0.2) + DrawImage(nil, 149, 0, 2, 300) + DrawImage(nil, 0, 149, 300, 2) + SetViewport() + SetDrawLayer(nil, 0) + end + end + + local blockedRects = { } + if viewerRect then + t_insert(blockedRects, viewerRect) + end + if hoverInfo.showStatTooltip then + SetDrawLayer(nil, 100) + self.resultTooltip:Clear() + local count = self.build:AddStatComparesToTooltip(self.resultTooltip, hoverData.baseOutput, hoverData.compareOutput, + hoverData.tooltipHeader or "^7Socketing this jewel will give you:") + if count == 0 then + self.resultTooltip:AddLine(14, "^7No stat changes for this result.") + end + local ttW, ttH = self.resultTooltip:GetSize() + local ttX, ttY = placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRects) + self.resultTooltip:Draw(ttX, ttY, nil, nil, viewPort) + t_insert(blockedRects, { x = ttX, y = ttY, width = ttW, height = ttH }) + SetDrawLayer(nil, 0) + end + if hoverInfo.showItemTooltip then + SetDrawLayer(nil, 100) + self.itemTooltip:Clear(true) + for _, line in ipairs(hoverData.itemTooltipLines) do + self.itemTooltip:AddLine(line.height or 16, line[1], line.font) + end + local itemTtW, itemTtH = self.itemTooltip:GetSize() + local itemTtX, itemTtY = placeResultTooltip(itemTtW, itemTtH, cursorX, cursorY, blockedRects) + self.itemTooltip:Draw(itemTtX, itemTtY, nil, nil, viewPort) + SetDrawLayer(nil, 0) + end +end + +---@class RadiusJewelFinder +local RadiusJewelFinderClass = newClass("RadiusJewelFinder") + +function RadiusJewelFinderClass:RadiusJewelFinder(treeTab) + self.treeTab = treeTab + self.build = treeTab.build + return self +end + +local function normalizeImpactStat(impactStat) + if type(impactStat) == "string" then + return { + field = impactStat, + label = impactStat, + selection = { stat = impactStat, label = impactStat }, + } + elseif impactStat and impactStat.stat and not impactStat.selection then + return { + field = impactStat.stat, + label = impactStat.label, + selection = impactStat, + } + end + return impactStat +end + +function RadiusJewelFinderClass:getImpactValue(impactStat, output) + impactStat = normalizeImpactStat(impactStat) + local selection = impactStat.selection or impactStat + if selection.getValue then + return selection.getValue(output, self.build) + end + local statOutput = output + if statOutput and statOutput.Minion and selection.stat ~= "FullDPS" then + statOutput = statOutput.Minion + end + local value = statOutput and (statOutput[selection.stat] or 0) or 0 + if selection.transform then + value = selection.transform(value) + end + return value +end + +function RadiusJewelFinderClass:calculateImpactDelta(impactStat, baselineOutput, compareOutput) + impactStat = normalizeImpactStat(impactStat) + local selection = impactStat.selection or impactStat + return self.build.calcsTab:CalculatePowerStat(selection, compareOutput, baselineOutput) +end + +local function calculateImpactPercent(delta, baseline) + local baselineMagnitude = m_abs(baseline) + return baselineMagnitude > 0 and (delta / baselineMagnitude * 100) or 0 +end + +-- Data module imports +local IMPACT_STATS = RadiusJewelData.buildImpactStats() +local DISCONNECTED_PASSIVE_COMPUTE_METHODS = RadiusJewelData.DISCONNECTED_PASSIVE_COMPUTE_METHODS +local OCCUPIED_SOCKET_OPTIONS = RadiusJewelData.OCCUPIED_SOCKET_OPTIONS +local jewelPreviewFn = RadiusJewelData.jewelPreviewFn +local scoreAllocPassives = RadiusJewelData.scoreAllocPassives +local buildJewelTypes = RadiusJewelData.buildJewelTypes +local jewelTypeSortOrder = RadiusJewelData.jewelTypeSortOrder +local makeVariantDropdownEntry = RadiusJewelData.makeVariantDropdownEntry +local findDisconnectedPassiveComputeMethod = RadiusJewelData.findDisconnectedPassiveComputeMethod +local getSplitPersonalityVariants = RadiusJewelData.getSplitPersonalityVariants +local getImpossibleEscapeVariants = RadiusJewelData.getImpossibleEscapeVariants +local mustGetUniqueRawText = RadiusJewelData.mustGetUniqueRawText + +-- Exposed for testing; calls the data module helper. +function RadiusJewelFinderClass:buildVariantsFromUniqueItem(uniqueName, baseName) + return RadiusJewelData.buildVariantsFromUniqueItem(uniqueName, baseName) +end + +function RadiusJewelFinderClass:discoverFoulbornVariants(uniqueName, radiusIndexByLabel) + return RadiusJewelData.discoverFoulbornVariants(uniqueName, radiusIndexByLabel) +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Build jewel socket list +-- ───────────────────────────────────────────────────────────────────────────── + +function RadiusJewelFinderClass:buildJewelSockets(largeRadiusIndex) + local treeData = self.build.spec.tree + local allocNodes = self.build.spec.allocNodes + local sockets = { } + for socketId, socketData in pairs(self.build.spec.nodes) do + if socketData.isJewelSocket and socketData.name ~= "Charm Socket" then + local keystone = "Unknown" + local minDist = m_huge + local socketNode = treeData.nodes[socketId] + if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[largeRadiusIndex] then + for _, n in pairs(socketNode.nodesInRadius[largeRadiusIndex]) do + if n.isKeystone then + local dx = n.x - socketData.x + local dy = n.y - socketData.y + local d = dx * dx + dy * dy + if d < minDist then keystone = n.dn or n.name or "Unknown"; minDist = d end + end + end + end + local prefix = allocNodes[socketId] and "# " or "" + local pd = socketData.pathDist or 0 + local classStartDist = self:getSocketDistanceToClassStart(socketId) + local distStr = (not allocNodes[socketId] and pd < 999) and s_format(" [+%d]", pd) or "" + local label = prefix .. keystone .. " (" .. socketId .. ")" .. distStr + t_insert(sockets, { label = label, id = socketId, pathDist = pd, classStartDist = classStartDist }) + end + end + t_sort(sockets, function(a, b) return a.label < b.label end) + return sockets +end + +-- Occupancy is the socket's current item state plus whether a preview replace is safe. +function RadiusJewelFinderClass:getSocketOccupancyInfo(socketId) + local slot = self.build.itemsTab.sockets[socketId] + local isSocketAllocated = self.build.spec.allocNodes[socketId] ~= nil + if not slot or slot.selItemId == 0 then + return { + slot = slot, + isSocketAllocated = isSocketAllocated, + isOccupied = false, + isSafeReplace = true, + } + end + local item = self.build.itemsTab.items[slot.selItemId] + local itemLabel = item and (item.title or item.name or item.baseName) or "Unknown item" + if not isSocketAllocated then + return { + slot = slot, + item = item, + itemLabel = itemLabel, + isSocketAllocated = false, + isOccupied = false, + isSafeReplace = true, + storedUnallocatedItemLabel = itemLabel, + } + end + local isPositionSensitive = false + if item then + isPositionSensitive = item.clusterJewel + or item.jewelRadiusIndex ~= nil + or (item.jewelData and item.jewelData.impossibleEscapeKeystones ~= nil) + or (item.title and item.title:match("^Split Personality") ~= nil) + end + return { + slot = slot, + item = item, + itemLabel = itemLabel, + isSocketAllocated = true, + isOccupied = true, + isSafeReplace = not isPositionSensitive, + replacedItemLabel = itemLabel, + } +end + +function RadiusJewelFinderClass:socketMatchesOccupiedMode(socketId, occupiedMode) + local occupancy = self:getSocketOccupancyInfo(socketId) + if not occupancy.isOccupied then + return true, occupancy + end + if not occupiedMode or occupiedMode.id == "free" then + return false, occupancy + elseif occupiedMode.id == "safe" then + return occupancy.isSafeReplace, occupancy + end + return true, occupancy +end + +function RadiusJewelFinderClass:getSocketBasePoints(socket, occupancy) + local socketId = type(socket) == "table" and socket.id or socket + occupancy = occupancy or self:getSocketOccupancyInfo(socketId) + -- Socket base points are the passive points needed to reach an empty socket; occupied sockets are already paid for. + if occupancy and occupancy.isOccupied then + return 0 + end + return type(socket) == "table" and (socket.pathDist or 0) or 0 +end + +-- Find all sockets where a jewel matching this type is currently equipped. +-- Returns a list of { socketId, slot, itemId, item } entries with an .atLimit flag. +-- .atLimit is true when the jewel has a limit and the number of equipped copies >= that limit. +function RadiusJewelFinderClass:findEquippedJewelSockets(jewelType) + local equipped = { } + local limit + local allocNodes = self.build.spec.allocNodes + for socketId, slot in pairs(self.build.itemsTab.sockets) do + if allocNodes[socketId] and slot.selItemId and slot.selItemId ~= 0 then + local item = self.build.itemsTab.items[slot.selItemId] + if item and item.title == jewelType.name then + limit = limit or item.limit + t_insert(equipped, { + socketId = socketId, + slot = slot, + itemId = slot.selItemId, + item = item, + }) + end + end + end + equipped.atLimit = limit ~= nil and #equipped >= limit + return equipped +end + +-- Disconnected-passive jewels allocate passives "without being connected to your tree". +-- Find allocated nodes that depend on Intuitive Leap, Inspired Learning, or Thread of Hope. +-- Returns a list of nodeIds that should be temporarily unallocated. +function RadiusJewelFinderClass:findDisconnectedPassiveDependentNodes(socketId, item) + local spec = self.build.spec + local treeData = spec.tree or self.build.tree + local socketNode = treeData.nodes[socketId] + if not socketNode then return { } end + + -- Collect all nodes in the jewel's radius + local radiusNodes = { } + if item.jewelData and item.jewelData.impossibleEscapeKeystones then + -- IE: nodes in Small radius around each keystone + local smallRI + for i, radius in ipairs(data.jewelRadius) do + if radius.label == "Small" and radius.inner == 0 then + smallRI = i + break + end + end + if smallRI and treeData.keystoneMap then + for keystoneName, _ in pairs(item.jewelData.impossibleEscapeKeystones) do + local ksNode = treeData.keystoneMap[keystoneName] + if ksNode and ksNode.nodesInRadius and ksNode.nodesInRadius[smallRI] then + for nodeId, node in pairs(ksNode.nodesInRadius[smallRI]) do + radiusNodes[nodeId] = node + end + end + end + end + elseif item.jewelRadiusIndex and socketNode.nodesInRadius then + -- Inspired Learning / Thread of Hope: nodes in the jewel's radius around the socket + local nodes = socketNode.nodesInRadius[item.jewelRadiusIndex] + if nodes then + for nodeId, node in pairs(nodes) do + radiusNodes[nodeId] = node + end + end + end + + -- Find allocated nodes in the radius + local allocInRadius = { } + for nodeId, _ in pairs(radiusNodes) do + if spec.allocNodes[nodeId] then + allocInRadius[nodeId] = true + end + end + if not next(allocInRadius) then return { } end + + -- Search linked allocated nodes away from the radius edge. + -- Note: spec.nodes has `linked`; treeData.nodes does not. + local specNodes = spec.nodes + local connected = { } + local queue = { } + for nodeId, _ in pairs(allocInRadius) do + local node = specNodes[nodeId] + if node and node.linked then + for _, other in ipairs(node.linked) do + if spec.allocNodes[other.id] and not radiusNodes[other.id] then + connected[nodeId] = true + t_insert(queue, nodeId) + break + end + end + end + end + -- Continue connectivity within the radius + local qi = 1 + while qi <= #queue do + local nodeId = queue[qi] + qi = qi + 1 + local node = specNodes[nodeId] + if node and node.linked then + for _, other in ipairs(node.linked) do + if allocInRadius[other.id] and not connected[other.id] then + connected[other.id] = true + t_insert(queue, other.id) + end + end + end + end + + -- Nodes in radius that are allocated but NOT connected from outside the radius + local dependent = { } + for nodeId, _ in pairs(allocInRadius) do + if not connected[nodeId] then + t_insert(dependent, nodeId) + end + end + return dependent +end + +function RadiusJewelFinderClass:removeEquippedJewels(equippedList) + local spec = self.build.spec + for _, entry in ipairs(equippedList) do + -- Find disconnected passive dependent nodes before removing the item + entry.savedAllocNodes = { } + local dependentNodes = self:findDisconnectedPassiveDependentNodes(entry.socketId, entry.item) + for _, nodeId in ipairs(dependentNodes) do + entry.savedAllocNodes[nodeId] = spec.allocNodes[nodeId] + spec.allocNodes[nodeId] = nil + end + -- Remove the jewel from the socket + entry.savedSelItemId = entry.slot.selItemId + entry.savedSpecJewel = spec.jewels[entry.socketId] + entry.slot.selItemId = 0 + spec.jewels[entry.socketId] = 0 + end + return equippedList +end + +function RadiusJewelFinderClass:restoreEquippedJewels(equippedList) + local spec = self.build.spec + for _, entry in ipairs(equippedList) do + if entry.savedSelItemId then + entry.slot.selItemId = entry.savedSelItemId + spec.jewels[entry.socketId] = entry.savedSpecJewel + entry.savedSelItemId = nil + entry.savedSpecJewel = nil + end + if entry.savedAllocNodes then + for nodeId, node in pairs(entry.savedAllocNodes) do + spec.allocNodes[nodeId] = node + end + entry.savedAllocNodes = nil + end + end +end + +local function buildNodeLabelList(nodes) + local labels = { } + for _, node in ipairs(nodes or { }) do + if type(node) == "table" then + t_insert(labels, node.label or node.dn or node.name or tostring(node.id or "?")) + else + t_insert(labels, tostring(node)) + end + end + return labels +end + +-- Attach compute methods and get the UI helper +local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCompute")(RadiusJewelFinderClass, { + extractTooltipStats = extractTooltipStats, + normalizeImpactStat = normalizeImpactStat, + calculateImpactPercent = calculateImpactPercent, + mustGetUniqueRawText = mustGetUniqueRawText, +}) + +-- ───────────────────────────────────────────────────────────────────────────── +-- Best-per-socket allocation +-- ───────────────────────────────────────────────────────────────────────────── + +--- Filter rows to keep at most one result per socket while applying jewel limits +--- and use socket-dependent jewels before socket-independent ones. +--- +--- Each row is expected to carry: +--- socketId (number) – jewel socket id +--- sortPctPerPoint / scorePerPointSort (number) – sort key (higher = better) +--- isSocketIndependent (boolean?) – true for jewels like IE +--- jewelLimitKey (string?) – key for the "Limited to: X" cap +--- jewelLimit (number?) – max copies allowed (nil = unlimited) +--- points (number?) – total points (tie-break for independent) +function RadiusJewelFinderClass:filterBestPerSocket(rows) + local sorted = { } + for _, row in ipairs(rows) do + t_insert(sorted, row) + end + t_sort(sorted, function(a, b) + return (a.sortPctPerPoint or a.scorePerPointSort or 0) > (b.sortPctPerPoint or b.scorePerPointSort or 0) + end) + local usedSockets = { } + local limitCounts = { } + local filtered = { } + -- Pass 1: assign socket-dependent jewels first (they need specific sockets) + for _, row in ipairs(sorted) do + if not row.isSocketIndependent and not usedSockets[row.socketId] then + local limitKey = row.jewelLimitKey + local limit = row.jewelLimit + if not limit or (limitCounts[limitKey] or 0) < limit then + usedSockets[row.socketId] = true + if limitKey and limit then + limitCounts[limitKey] = (limitCounts[limitKey] or 0) + 1 + end + t_insert(filtered, row) + end + end + end + -- Pass 2: assign socket-independent jewels (e.g. IE) to remaining sockets, fewer points first + local independentSorted = { } + for _, row in ipairs(sorted) do + if row.isSocketIndependent then + t_insert(independentSorted, row) + end + end + t_sort(independentSorted, function(a, b) + local aScore = a.sortPctPerPoint or a.scorePerPointSort or 0 + local bScore = b.sortPctPerPoint or b.scorePerPointSort or 0 + if aScore ~= bScore then + return aScore > bScore + end + return (a.points or 0) < (b.points or 0) + end) + for _, row in ipairs(independentSorted) do + if not usedSockets[row.socketId] then + local limitKey = row.jewelLimitKey + local limit = row.jewelLimit + if not limit or (limitCounts[limitKey] or 0) < limit then + usedSockets[row.socketId] = true + if limitKey and limit then + limitCounts[limitKey] = (limitCounts[limitKey] or 0) + 1 + end + t_insert(filtered, row) + end + end + end + t_sort(filtered, function(a, b) + return (a.sortPctPerPoint or a.scorePerPointSort or 0) > (b.sortPctPerPoint or b.scorePerPointSort or 0) + end) + return filtered +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Open popup +-- ───────────────────────────────────────────────────────────────────────────── + +function RadiusJewelFinderClass:Open() + local treeData = self.build.spec.tree + + -- Radius index map + local radiusIndexByLabel = { } + for i, r in ipairs(data.jewelRadius) do + if r.inner == 0 and not radiusIndexByLabel[r.label] then + radiusIndexByLabel[r.label] = i + end + end + + -- Thread of Hope ring variants (inner radius > 0) + local threadVariants = { } + local threadRawText = mustGetUniqueRawText("Thread of Hope") + local threadItem = new("Item"):Item("Rarity: Unique\n" .. threadRawText) + local tIdx = 1 + for i, r in ipairs(data.jewelRadius) do + if r.inner > 0 then + local ringName = threadItem.variantList and threadItem.variantList[tIdx] + if ringName then + ringName = ringName:gsub(" Ring$", "") + else + ringName = "Ring " .. tIdx + end + t_insert(threadVariants, { name = ringName, radiusIndex = i }) + tIdx = tIdx + 1 + end + end + + local LARGE_IDX = radiusIndexByLabel["Large"] + local jewelTypes + local jewelSockets = self:buildJewelSockets(LARGE_IDX) + + -- Mutable state + local showLegacy = false + local activeJewelTypes = { } -- filtered view of jewelTypes + local selectedJewelType = nil -- set after first filter build + local selectedThreadVariant = threadVariants[1] + local selectedJewelVariant = nil -- set when jewel type has built-in variants + local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1] + local selectedMaxPoints = 20 + local selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[1] + local dreamFamilyOptions = { + { name = "All", value = "ALL" }, + { name = "Red Dream", value = "The Red Dream" }, + { name = "Red Nightmare", value = "The Red Nightmare" }, + { name = "Green Dream", value = "The Green Dream" }, + { name = "Green Nightmare", value = "The Green Nightmare" }, + { name = "Blue Dream", value = "The Blue Dream" }, + { name = "Blue Nightmare", value = "The Blue Nightmare" }, + } + local selectedDreamFamily = dreamFamilyOptions[1] + + local TL = { "TOPLEFT", nil, "TOPLEFT" } + local BL = { "BOTTOMLEFT", nil, "BOTTOMLEFT" } + local BR = { "BOTTOMRIGHT", nil, "BOTTOMRIGHT" } + local edgePadding = 10 + local buttonHeight = 20 + local leftPanelWidth = 580 + local rightPanelWidth = 410 + local popupWidth = edgePadding * 3 + leftPanelWidth + rightPanelWidth + local popupHeight = 474 + local rightPanelX = edgePadding * 2 + leftPanelWidth + local bottomButtonY = -edgePadding + local bottomInputY = -(edgePadding + 2) + local bottomLabelY = -(edgePadding + 4) + local controls = { } + local applySelectedResult -- set below; used by OnSelClick + applyButton + + -- ── Dropdown label lists ────────────────────────────────────────────────── + -- (jtLabels is built dynamically via rebuildJewelTypeDropdown) + local jtLabels = { } + + local tvLabels = { } + for _, tv in ipairs(threadVariants) do t_insert(tvLabels, tv.name .. " Ring") end + + local socketViewer = new("PassiveTreeView"):PassiveTreeView() + + local impactStatLabels = { } + for _, s in ipairs(IMPACT_STATS) do t_insert(impactStatLabels, s.label) end + local occupiedModeLabels = { } + for _, option in ipairs(OCCUPIED_SOCKET_OPTIONS) do t_insert(occupiedModeLabels, option.label) end + local selectedImpactStat = IMPACT_STATS[1] + local finderState = self.build.radiusJewelFinderState or { } + self.build.radiusJewelFinderState = finderState + finderState.findCache = finderState.findCache or { } + finderState.computeCache = finderState.computeCache or { } + finderState.resultViewByKey = finderState.resultViewByKey or { } + finderState.disconnectedPassivePlanCache = finderState.disconnectedPassivePlanCache or { } + local ALL_JEWELS_VIEW_OPTIONS = { + { id = "all", label = "All results" }, + { id = "bestPerSocket", label = "Best per socket" }, + } + local allJewelsViewLabels = { } + for _, v in ipairs(ALL_JEWELS_VIEW_OPTIONS) do t_insert(allJewelsViewLabels, v.label) end + local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1] + local lastComputeAllRows = nil + + local function filterBestPerSocket(rows) + return self:filterBestPerSocket(rows) + end + + local suppressFinderStateSave = false + local runFind + local computeContext + local cancelCompute + local searchStartTime + + local function formatElapsed(startTime) + if not startTime then return "" end + local ms = GetTime() - startTime + if ms < 1000 then + return s_format(" ^8(%d ms)", ms) + end + return s_format(" ^8(%.1fs)", ms / 1000) + end + + local function saveFinderState() + if suppressFinderStateSave then + return + end + finderState.showLegacy = showLegacy + finderState.jewelTypeName = selectedJewelType and selectedJewelType.name or nil + finderState.jewelVariantName = selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or nil + finderState.threadVariantName = selectedThreadVariant and selectedThreadVariant.name or nil + finderState.dreamFamilyValue = selectedDreamFamily and selectedDreamFamily.value or nil + finderState.impactStatLabel = selectedImpactStat and selectedImpactStat.label or nil + finderState.computeMethodId = selectedComputeMethod and selectedComputeMethod.id or nil + finderState.maxPoints = selectedMaxPoints + finderState.occupiedModeId = selectedOccupiedMode and selectedOccupiedMode.id or nil + finderState.allJewelsViewId = selectedAllJewelsView and selectedAllJewelsView.id or nil + end + + local function getSelectionKey() + local supportsComputeMethods = selectedJewelType and selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0 + local computeMethodKey = supportsComputeMethods and selectedComputeMethod and selectedComputeMethod.id or "" + return table.concat({ + tostring(showLegacy and 1 or 0), + selectedJewelType and selectedJewelType.name or "", + selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or "", + selectedThreadVariant and selectedThreadVariant.name or "", + selectedDreamFamily and selectedDreamFamily.value or "", + selectedImpactStat and selectedImpactStat.field or "", + computeMethodKey, + selectedMaxPoints and tostring(selectedMaxPoints) or "", + selectedOccupiedMode and selectedOccupiedMode.id or "", + }, "|") + end + + local function restoreCachedResults() + local key = getSelectionKey() + local preferredView = finderState.resultViewByKey[key] + local allowFindCache = not (selectedJewelType and selectedJewelType.isAllJewels) + local findCache = allowFindCache and finderState.findCache[key] or nil + local computeCache = finderState.computeCache[key] + local cache = preferredView == "compute" and computeCache or findCache + if not cache and preferredView == "compute" then + cache = findCache + elseif not cache and preferredView == "find" then + cache = computeCache + end + if not cache then + cache = findCache or computeCache + end + if not cache then + return false + end + local rows = copyTableSafe(cache.rows, false, true) + if cache.mode == "computeSocketAll" then + lastComputeAllRows = rows + if selectedAllJewelsView.id == "bestPerSocket" then + rows = filterBestPerSocket(rows) + end + end + controls.resultsList:SetMode(cache.mode, rows, cache.defaultText) + controls.statusLabel.label = cache.statusLabel or controls.statusLabel.label + return true + end + local function saveResultCache(viewName, mode, rows, defaultText, statusLabel, makePreferred) + local key = getSelectionKey() + local targetCache = viewName == "compute" and finderState.computeCache or finderState.findCache + targetCache[key] = { + mode = mode, + rows = copyTableSafe(rows, false, true), + defaultText = defaultText, + statusLabel = statusLabel, + } + if makePreferred then + finderState.resultViewByKey[key] = viewName + end + end + local function formatComputeStatus(itemLabel, statLabel, baseline, methodLabel) + if methodLabel and methodLabel ~= "" then + return s_format("^7%s | %s %.1f | %s | %%/pt", itemLabel, statLabel, baseline, methodLabel) + end + return s_format("^7%s | %s %.1f | %%/pt", itemLabel, statLabel, baseline) + end + local function formatReplacementLabel(replacedItemLabel) + return replacedItemLabel and ("Replace " .. replacedItemLabel) or "Free socket" + end + local function setComputeProgress(message) + controls.statusLabel.label = message + controls.resultsList:SetMode("message", { + { text = message }, + }, message) + end + cancelCompute = function(statusMessage) + if not computeContext then + return + end + if computeContext.removedJewels and #computeContext.removedJewels > 0 then + self:restoreEquippedJewels(computeContext.removedJewels) + end + main.onFrameFuncs["RadiusJewelFinderCompute"] = nil + computeContext = nil + if controls.computeButton then + controls.computeButton.label = "Compute" + end + if statusMessage then + controls.statusLabel.label = statusMessage + end + end + local function getSelectedComputeMethods() + if selectedJewelType and selectedJewelType.isAllJewels then + return DISCONNECTED_PASSIVE_COMPUTE_METHODS + end + if selectedJewelType and selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0 then + return selectedJewelType.computeMethods + end + end + local function selectedJewelSupportsComputeMethods() + local methods = getSelectedComputeMethods() + return methods and #methods > 0 + end + local function hasVariantFamilies() + if not selectedJewelType or not selectedJewelType.variants then return false end + for _, v in ipairs(selectedJewelType.variants) do + if v.family then return true end + end + return false + end + + local function getDisplayedVariants() + if not selectedJewelType or not selectedJewelType.variants then + return nil + end + if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= "ALL" then + local variants = { } + for _, variant in ipairs(selectedJewelType.variants) do + if variant.family == selectedDreamFamily.value then + t_insert(variants, variant) + end + end + return variants + end + return selectedJewelType.variants +end + +local function buildPreviewLinesForJewelType(jewelType, previewVariantOverride) + if not jewelType then + return nil + end + local fn = jewelPreviewFn[jewelType.name] + if not fn then + return nil + end + local selectedTypeMatches = selectedJewelType and selectedJewelType.name == jewelType.name + if jewelType.isThread then + local threadVariant = previewVariantOverride or selectedThreadVariant + return fn(threadVariant and threadVariant.name) + elseif jewelType.variants then + local previewVariant = previewVariantOverride or ((selectedTypeMatches and selectedJewelVariant) or jewelType.variants[1]) + return fn(previewVariant) + end + return fn() +end + +local function addPreviewLinesToTooltip(tooltip, lines) + if type(lines) ~= "table" then + return + end + tooltip:Clear(true) + for _, line in ipairs(lines) do + tooltip:AddLine(line.height or 16, line[1], line.font) + end +end + +local function buildGenericTypeTooltipLinesForJewelType(jewelType) + if not jewelType then + return nil + end + if not (jewelType.isThread or jewelType.variants) then + local lines = buildPreviewLinesForJewelType(jewelType) + if type(lines) ~= "table" then + return nil + end + return lines + end + local fn = jewelPreviewFn[jewelType.name] + local lines = fn and fn() or nil + if type(lines) ~= "table" then + return nil + end + + local genericLines = { } + local blankCount = 0 + for _, line in ipairs(lines) do + t_insert(genericLines, line) + if line[1] == "" then + blankCount = blankCount + 1 + if blankCount >= 2 then + break + end + end + end + local note + if jewelType.isThread then + note = "Multiple ring sizes available" + else + note = "Multiple variants available" + end + t_insert(genericLines, { height = 16, [1] = COL_META .. note }) + return genericLines +end + local function isAnyFinderDropdownDropped() + return (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) + or (controls.jewelVariantSelect and controls.jewelVariantSelect.dropped) + or (controls.threadVariantSelect and controls.threadVariantSelect.dropped) + or (controls.variantFamilySelect and controls.variantFamilySelect.dropped) + or (controls.allJewelsViewSelect and controls.allJewelsViewSelect.dropped) + or (controls.impactStatSelect and controls.impactStatSelect.dropped) + or (controls.occupiedModeSelect and controls.occupiedModeSelect.dropped) + end + + local function syncDisplayedVariants() + local variants = getDisplayedVariants() + if not variants then + controls.jewelVariantSelect:SetList({ }) + controls.jewelVariantSelect.selIndex = nil + selectedJewelVariant = nil + saveFinderState() + return + end + if #variants == 0 then + controls.jewelVariantSelect:SetList({ }) + controls.jewelVariantSelect.selIndex = nil + selectedJewelVariant = nil + saveFinderState() + return + end + local variantNames = { } + for _, v in ipairs(variants) do + t_insert(variantNames, makeVariantDropdownEntry(v)) + end + controls.jewelVariantSelect:SetList(variantNames) + local varIdx = 1 + if selectedJewelVariant then + for i, variant in ipairs(variants) do + if variant == selectedJewelVariant then + varIdx = i + break + end + end + else + varIdx = controls.jewelVariantSelect.selIndex or 1 + end + if varIdx > #variants then + varIdx = 1 + end + controls.jewelVariantSelect.selIndex = varIdx + selectedJewelVariant = variants[varIdx] + saveFinderState() + end + + -- ── Preview list (right panel) ──────────────────────────────────────────── + local previewListData = { } + local resultDetailListData = { } + local previewListY = 70 + local previewListHeight = 180 + local compactPreviewListHeight = 48 + local resultDetailBottomY = 430 + local resultDetailGap = 6 + local resultDetailLabelGap = 18 + local function getSelectedAllJewelPreviewLines() + local mode = controls.resultsList and controls.resultsList.mode + if mode ~= "computeSocketAll" then + return nil + end + local row = controls.resultsList.selValue + return row and row.itemTooltipLines or nil + end + local function isCompactPreview() + return selectedJewelType and selectedJewelType.isAllJewels and not getSelectedAllJewelPreviewLines() + end + local function getPreviewListHeight() + return isCompactPreview() and compactPreviewListHeight or previewListHeight + end + local function getResultDetailLabelY() + return previewListY + getPreviewListHeight() + resultDetailGap + end + local function getResultDetailListY() + return getResultDetailLabelY() + resultDetailLabelGap + end + local function updateResultDetails(row) + wipeTable(resultDetailListData) + if not row then + t_insert(resultDetailListData, { height = 16, [1] = COL_META .. "Select a result to view details." }) + return + end + t_insert(resultDetailListData, { height = 16, [1] = "^7Socket: " .. (row.socketLabel or "(n/a)") }) + if row.variantLabel and row.variantLabel ~= "" then + t_insert(resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel }) + end + if row.action == "keep" then + t_insert(resultDetailListData, { height = 16, [1] = "^8Already equipped" }) + elseif row.action == "moveReplace" then + t_insert(resultDetailListData, { height = 16, [1] = "^xBB88FFMove equipped jewel" }) + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. (row.replacedItemLabel or "?") }) + elseif row.action == "move" then + t_insert(resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" }) + elseif row.replacedItemLabel then + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" }) + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. row.replacedItemLabel }) + elseif row.storedUnallocatedItemLabel then + t_insert(resultDetailListData, { height = 16, [1] = "^2Use unallocated socket" }) + t_insert(resultDetailListData, { height = 16, [1] = "^8Stored jewel ignored until this socket is allocated." }) + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Apply will replace the stored jewel: ^7" .. row.storedUnallocatedItemLabel }) + else + t_insert(resultDetailListData, { height = 16, [1] = "^2Use free socket" }) + end + if row.detailText and row.detailText ~= "" then + t_insert(resultDetailListData, { height = 16, [1] = "^7" .. row.detailText }) + end + local nodeEntries = row.resultNodes or row.topNodes + if nodeEntries and #nodeEntries > 0 then + t_insert(resultDetailListData, { height = 6, [1] = "" }) + t_insert(resultDetailListData, { + height = 16, + [1] = row.resultNodes and s_format("^7Passives to allocate (%d):", #nodeEntries) + or s_format("^7Passives in range (%d):", #nodeEntries), + }) + for _, nodeEntry in ipairs(nodeEntries) do + t_insert(resultDetailListData, { + height = 16, + [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)), + nodeId = nodeEntry.nodeId, + }) + end + else + t_insert(resultDetailListData, { height = 6, [1] = "" }) + t_insert(resultDetailListData, { height = 16, [1] = row.resultNodes and (COL_META .. "No passives to allocate") or (COL_META .. "No passives in range") }) + end + end + controls.previewList = new("TextListControl"):TextListControl(TL, { rightPanelX, previewListY, rightPanelWidth, previewListHeight }, + { { x = 0, align = "LEFT" }, { x = 210, align = "LEFT" } }, previewListData) + controls.previewList.height = getPreviewListHeight + controls.previewList.shown = function() + return not (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) + end + controls.resultDetailLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, 256, 0, 16 }, "^7Details:") + controls.resultDetailLabel.y = getResultDetailLabelY + controls.resultDetailList = new("RadiusJewelDetailListControl"):RadiusJewelDetailListControl(TL, { rightPanelX, 274, rightPanelWidth, 156 }, + { { x = 0, align = "LEFT" } }, resultDetailListData, self.build, socketViewer) + controls.resultDetailList.y = getResultDetailListY + controls.resultDetailList.height = function() + return resultDetailBottomY - getResultDetailListY() + end + updateResultDetails(nil) + + local function addPreviewLines(lines) + if type(lines) ~= "table" then + return false + end + for _, line in ipairs(lines) do + t_insert(previewListData, line) + end + return #lines > 0 + end + + local function updatePreview(row) + wipeTable(previewListData) + if not selectedJewelType then + t_insert(previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) + return + end + if selectedJewelType.isAllJewels then + local mode = controls.resultsList and controls.resultsList.mode + if mode == "computeSocketAll" then + local previewRow = row or controls.resultsList.selValue + if previewRow and addPreviewLines(previewRow.itemTooltipLines) then + return + end + end + t_insert(previewListData, { height = 16, [1] = "^7Evaluate every jewel type." }) + if selectedAllJewelsView.id == "bestPerSocket" then + t_insert(previewListData, { height = 16, [1] = "^7Best jewel per socket." }) + else + t_insert(previewListData, { height = 16, [1] = "^7Sorted globally by %/Pt." }) + end + return + end + local lines = buildPreviewLinesForJewelType(selectedJewelType) + if type(lines) ~= "table" then + t_insert(previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) + return + end + addPreviewLines(lines) + end + + -- ── Results list (left panel) ───────────────────────────────────────────── + controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, 70, leftPanelWidth, 360 }, self.build, socketViewer) + controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped + controls.resultsList.OnSelect = function(_, _, row) + updateResultDetails(row) + updatePreview(row) + end + controls.resultsList.OnSelClick = function(_, index, value, doubleClick) + if doubleClick then + applySelectedResult() + end + end + controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") + + -- ── Helper: rebuild jewel type dropdown after filter change ────────────── + local function rebuildJewelTypeDropdown() + jewelTypes = buildJewelTypes(radiusIndexByLabel) + activeJewelTypes = { } + jtLabels = { } + for _, jt in ipairs(jewelTypes) do + if showLegacy or not jt.isLegacy then + t_insert(activeJewelTypes, jt) + end + end + t_sort(activeJewelTypes, function(a, b) + if a.name ~= b.name then + return a.name < b.name + end + if a.isLegacy ~= b.isLegacy then + return a.isLegacy == false + end + return false + end) + t_insert(activeJewelTypes, 1, { + name = "All jewels", + isAllJewels = true, + hasCompute = true, + }) + for _, jt in ipairs(activeJewelTypes) do + t_insert(jtLabels, jt.name) + end + if controls.jewelTypeSelect then + controls.jewelTypeSelect:SetList(jtLabels) + -- keep current selection if still visible, else reset to first + local selIdx = 1 + for i, jt in ipairs(activeJewelTypes) do + if selectedJewelType and jt.name == selectedJewelType.name then selIdx = i; break end + end + controls.jewelTypeSelect.selIndex = selIdx + selectedJewelType = activeJewelTypes[selIdx] + + local hasVariants = selectedJewelType.variants ~= nil + controls.jewelVariantLabel.shown = hasVariants + controls.jewelVariantSelect.shown = hasVariants + if hasVariants then + syncDisplayedVariants() + else + selectedJewelVariant = nil + end + saveFinderState() + else + -- initial build before controls exist + selectedJewelType = activeJewelTypes[1] + end + end + rebuildJewelTypeDropdown() -- initial build (controls.jewelTypeSelect not yet created) + + -- ── Header controls ─────────────────────────────────────────────────────── + controls.jewelTypeLabel = new("LabelControl"):LabelControl(TL, { edgePadding, 10, 0, 16 }, "^7Type:") + + controls.computeMethodLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, 10, 0, 16 }, "^7Method:") + controls.computeMethodSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX, 26, 160, buttonHeight }, { }, function(idx) + cancelCompute() + local methods = getSelectedComputeMethods() + if methods then + selectedComputeMethod = methods[idx] + end + saveFinderState() + end) + local function addComputeMethodTooltip(tooltip, mode, index) + local methods = getSelectedComputeMethods() + local method = (index and methods and methods[index]) or selectedComputeMethod + tooltip:Clear(true) + if selectedJewelType and selectedJewelType.isAllJewels then + tooltip:AddLine(16, "^7Used for Intuitive Leap, Thread of Hope, and Impossible Escape.") + else + tooltip:AddLine(16, "^7Controls how passives are selected for this jewel.") + end + if method and method.id == "simulated_greedy" then + tooltip:AddLine(16, "^8Simulated recalculates after each chosen passive.") + else + tooltip:AddLine(16, "^8Fast scores candidate passives independently.") + end + end + controls.computeMethodLabel.tooltipFunc = addComputeMethodTooltip + controls.computeMethodSelect.tooltipFunc = addComputeMethodTooltip + controls.computeMethodLabel.shown = false + controls.computeMethodSelect.shown = false + + -- Impact stat selector (shown when jewel has compute) + controls.impactStatLabel = new("LabelControl"):LabelControl(TL, { rightPanelX + 180, 10, 0, 16 }, "^7Stat:") + controls.impactStatSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX + 180, 26, 140, buttonHeight }, impactStatLabels, function(idx) + cancelCompute() + selectedImpactStat = IMPACT_STATS[idx] + saveFinderState() + end) + controls.impactStatLabel.shown = true + controls.impactStatSelect.shown = true + + controls.maxPointsLabel = new("LabelControl"):LabelControl(BL, { edgePadding + 110, bottomLabelY, 0, 16 }, "^7Max pts:") + controls.maxPointsEdit = new("EditControl"):EditControl(BL, { edgePadding + 172, bottomInputY, 56, buttonHeight }, tostring(selectedMaxPoints), nil, "%D", 3, function(buf) + cancelCompute() + selectedMaxPoints = buf ~= "" and tonumber(buf) or nil + saveFinderState() + end) + local function addMaxPointsTooltip(tooltip) + tooltip:Clear(true) + tooltip:AddLine(16, "^7Maximum total passive points for a result.") + tooltip:AddLine(16, "^8Includes pathing to the socket and passives to allocate.") + end + controls.maxPointsLabel.tooltipFunc = addMaxPointsTooltip + controls.maxPointsEdit.tooltipFunc = addMaxPointsTooltip + controls.maxPointsLabel.shown = true + controls.maxPointsEdit.shown = true + + controls.occupiedModeLabel = new("LabelControl"):LabelControl(BL, { edgePadding + 240, bottomLabelY, 0, 16 }, "^7Sockets:") + controls.occupiedModeSelect = new("DropDownControl"):DropDownControl(BL, { edgePadding + 298, bottomInputY, 170, buttonHeight }, occupiedModeLabels, function(idx) + cancelCompute() + selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[idx] + saveFinderState() + runFind(false) + end) + local function addOccupiedModeTooltip(tooltip, mode, index) + local option = (index and OCCUPIED_SOCKET_OPTIONS[index]) or selectedOccupiedMode + tooltip:Clear(true) + if not option or option.id == "free" then + tooltip:AddLine(16, "^7Only try empty jewel sockets.") + elseif option.id == "safe" then + tooltip:AddLine(16, "^7Try empty sockets and safe occupied sockets.") + tooltip:AddLine(16, "^8Safe means the current jewel has no socket-specific behavior.") + else + tooltip:AddLine(16, "^7Try empty and occupied jewel sockets.") + tooltip:AddLine(16, "^8May suggest replacing socket-specific jewels.") + end + end + controls.occupiedModeLabel.tooltipFunc = addOccupiedModeTooltip + controls.occupiedModeSelect.tooltipFunc = addOccupiedModeTooltip + controls.occupiedModeLabel.shown = true + controls.occupiedModeSelect.shown = true + + -- All-jewels view mode selector + controls.allJewelsViewLabel = new("LabelControl"):LabelControl(TL, { 278, 10, 0, 16 }, "^7View:") + controls.allJewelsViewSelect = new("DropDownControl"):DropDownControl(TL, { 278, 26, 160, 20 }, allJewelsViewLabels, function(idx) + selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[idx] + if lastComputeAllRows then + local displayRows = selectedAllJewelsView.id == "bestPerSocket" + and filterBestPerSocket(lastComputeAllRows) or lastComputeAllRows + controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)") + end + saveFinderState() + end) + local function addAllJewelsViewTooltip(tooltip, mode, index) + local option = (index and ALL_JEWELS_VIEW_OPTIONS[index]) or selectedAllJewelsView + tooltip:Clear(true) + if option and option.id == "bestPerSocket" then + tooltip:AddLine(16, "^7Keep one best result per socket.") + tooltip:AddLine(16, "^8Jewel limits still apply.") + else + tooltip:AddLine(16, "^7Show every compatible result.") + end + end + controls.allJewelsViewLabel.tooltipFunc = addAllJewelsViewTooltip + controls.allJewelsViewSelect.tooltipFunc = addAllJewelsViewTooltip + controls.allJewelsViewLabel.shown = false + controls.allJewelsViewSelect.shown = false + + -- Thread ring selector (shown when Thread of Hope selected) + controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { 278, 10, 0, 16 }, "^7Preview ring:") + controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { 278, 26, 200, 20 }, tvLabels, function(idx) + cancelCompute() + selectedThreadVariant = threadVariants[idx] + saveFinderState() + updatePreview() + runFind(false) + end) + controls.threadVariantLabel.shown = false + controls.threadVariantSelect.shown = false + + controls.variantFamilyLabel = new("LabelControl"):LabelControl(TL, { 550, 10, 0, 16 }, "^7Family:") + controls.variantFamilySelect = new("DropDownControl"):DropDownControl(TL, { 550, 26, 220, 20 }, { + "All", + "Red Dream", + "Red Nightmare", + "Green Dream", + "Green Nightmare", + "Blue Dream", + "Blue Nightmare", + }, function(idx) + cancelCompute() + selectedDreamFamily = dreamFamilyOptions[idx] + controls.jewelVariantSelect.selIndex = 1 + selectedJewelVariant = nil + syncDisplayedVariants() + saveFinderState() + updatePreview() + runFind(false) + end) + controls.variantFamilyLabel.shown = false + controls.variantFamilySelect.shown = false + + -- Jewel variant selector (shown when jewel type has built-in variants) + controls.jewelVariantLabel = new("LabelControl"):LabelControl(TL, { 278, 10, 0, 16 }, "^7Variant:") + controls.jewelVariantSelect = new("DropDownControl"):DropDownControl(TL, { 278, 26, 260, 20 }, {}, function(idx) + cancelCompute() + local variants = getDisplayedVariants() + if variants then + selectedJewelVariant = variants[idx] + saveFinderState() + updatePreview() + end + end) + controls.jewelVariantSelect.enableDroppedWidth = true + controls.jewelVariantSelect.maxDroppedWidth = 520 + controls.jewelVariantLabel.shown = false + controls.jewelVariantSelect.shown = false + + local function syncComputeMethodSelect(methods) + methods = methods or getSelectedComputeMethods() + if not methods or #methods == 0 then + controls.computeMethodSelect:SetList({ }) + controls.computeMethodSelect.selIndex = nil + return + end + local methodLabels = { } + for _, method in ipairs(methods) do + t_insert(methodLabels, method.label) + end + local selectedIndex = 1 + for i, method in ipairs(methods) do + if selectedComputeMethod and method.id == selectedComputeMethod.id then + selectedIndex = i + break + end + end + selectedComputeMethod = methods[selectedIndex] + controls.computeMethodSelect:SetList(methodLabels) + controls.computeMethodSelect.selIndex = selectedIndex + end + + local function syncSelectedJewelTypeControls() + if selectedJewelType.isAllJewels then + controls.allJewelsViewLabel.shown = true + controls.allJewelsViewSelect.shown = true + controls.threadVariantLabel.shown = false + controls.threadVariantSelect.shown = false + controls.variantFamilyLabel.shown = false + controls.variantFamilySelect.shown = false + controls.jewelVariantLabel.shown = false + controls.jewelVariantSelect.shown = false + controls.computeMethodLabel.shown = true + controls.computeMethodSelect.shown = true + controls.impactStatLabel.shown = true + controls.impactStatSelect.shown = true + syncComputeMethodSelect(DISCONNECTED_PASSIVE_COMPUTE_METHODS) + if controls.computeButton then + controls.computeButton.shown = true + end + if controls.findButton then + controls.findButton.shown = false + end + selectedJewelVariant = nil + return + end + controls.allJewelsViewLabel.shown = false + controls.allJewelsViewSelect.shown = false + local isThread = selectedJewelType.isThread == true + local hasVariants = selectedJewelType.variants ~= nil + local hasVariantFamilyFilter = hasVariantFamilies() + local hasComputeMethods = selectedJewelSupportsComputeMethods() + + controls.threadVariantLabel.shown = isThread + controls.threadVariantSelect.shown = isThread + controls.variantFamilyLabel.shown = hasVariantFamilyFilter + controls.variantFamilySelect.shown = hasVariantFamilyFilter + controls.jewelVariantLabel.shown = hasVariants + controls.jewelVariantSelect.shown = hasVariants + controls.computeMethodLabel.shown = hasComputeMethods + controls.computeMethodSelect.shown = hasComputeMethods + controls.impactStatLabel.shown = selectedJewelType.hasCompute + controls.impactStatSelect.shown = selectedJewelType.hasCompute + if controls.findButton then + controls.findButton.shown = true + end + if controls.computeButton then + controls.computeButton.shown = selectedJewelType.hasCompute + end + + if hasVariants then + if not hasVariantFamilyFilter then + selectedDreamFamily = dreamFamilyOptions[1] + controls.variantFamilySelect.selIndex = 1 + end + syncDisplayedVariants() + else + selectedJewelVariant = nil + end + if hasComputeMethods then + syncComputeMethodSelect(selectedJewelType.computeMethods) + end + end + + -- Jewel type dropdown (defined after variant controls so :Click() is safe) + controls.jewelTypeSelect = new("DropDownControl"):DropDownControl(TL, { 10, 26, 260, 20 }, jtLabels, function(idx) + cancelCompute() + selectedJewelType = activeJewelTypes[idx] + controls.jewelVariantSelect.selIndex = 1 + syncSelectedJewelTypeControls() + saveFinderState() + updatePreview() + runFind(false) + end) + controls.jewelTypeSelect.tooltipFunc = function(tooltip, mode, index) + local jewelType = activeJewelTypes[index] + if jewelType and jewelType.isAllJewels then + tooltip:Clear(true) + tooltip:AddLine(16, "^7Evaluate every jewel type at once.") + tooltip:AddLine(16, "^7Results sorted globally by %/Pt.") + return + end + addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(jewelType)) + end + controls.jewelVariantSelect.tooltipFunc = function(tooltip, mode, index) + local variants = getDisplayedVariants() + local variant = variants and variants[index] + if not selectedJewelType or not variant then + return + end + addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant)) + end + controls.threadVariantSelect.tooltipFunc = function(tooltip, mode, index) + local variant = threadVariants[index] + if not selectedJewelType or not variant then + return + end + addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant)) + end + syncSelectedJewelTypeControls() + + -- Compute button: socket sorting with best variant per socket + -- Results go into the left panel (resultListData); jewel preview is unchanged. + local function makeComputeProgressTracker() + local tracker + local function setFraction(self, fraction, label) + local nextFraction = math.max(0, math.min(fraction or 0, 1)) + if nextFraction < self.fraction then + nextFraction = self.fraction + end + self.fraction = nextFraction + local pct = math.floor(nextFraction * 100) + local text = label and s_format("^7Computing... %d%% | %s", pct, label) or s_format("^7Computing... %d%%", pct) + setComputeProgress(text) + local now = GetTime() + if now - self.lastYield > 50 then + self.lastYield = now + coroutine.yield() + end + end + local function makeChild(root, startFraction, spanFraction) + return { + root = root, + startFraction = startFraction or 0, + spanFraction = spanFraction or 1, + tick = function(self, done, total, label) + local localFraction = total and total > 0 and (done / total) or 0 + self.root:setFraction(self.startFraction + localFraction * self.spanFraction, label) + end, + child = function(self, childStartFraction, childSpanFraction) + return makeChild( + self.root, + self.startFraction + (childStartFraction or 0) * self.spanFraction, + (childSpanFraction or 1) * self.spanFraction + ) + end, + } + end + tracker = { + lastYield = GetTime(), + fraction = 0, + setFraction = setFraction, + tick = function(self, done, total, label) + local fraction = total and total > 0 and (done / total) or 0 + self:setFraction(fraction, label) + end, + child = function(self, startFraction, spanFraction) + return makeChild(self, startFraction, spanFraction) + end, + } + return tracker + end + local function buildComputeRows(jewelType, socketResults, baseline, equippedList) + local equippedSocketIds = { } + local existingSocketId + for _, entry in ipairs(equippedList or { }) do + equippedSocketIds[entry.socketId] = true + if equippedList.atLimit then + existingSocketId = existingSocketId or entry.socketId + end + end + -- For limited jewels at capacity, find the keep delta so move rows show the net effect + local keepDelta = 0 + if existingSocketId then + for _, r in ipairs(socketResults) do + if equippedSocketIds[r.socket.id] then + keepDelta = r.delta or 0 + break + end + end + end + local rows = { } + for _, r in ipairs(socketResults) do + local isEquippedSocket = equippedSocketIds[r.socket.id] + local points = isEquippedSocket and 0 + or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil }) + local variantLabel = r.variant and (r.variant.dropdownLabel or r.variant.name) or "" + local itemTooltipLines = buildPreviewLinesForJewelType(jewelType, r.variant) + local applyRawText = r.variant and r.variant.rawText or jewelType.rawText + local jewelLimitKey = applyRawText and applyRawText:match("^([^\n]+)") or jewelType.name + local jewelLimit = jewelType.limit or (applyRawText and tonumber(applyRawText:match("Limited to: (%d+)"))) or nil + local displayedPlans = (jewelType.name == "Intuitive Leap" or jewelType.isThread or jewelType.isImpossibleEscape) + and buildDisplayedDisconnectedPassivePlans(r, points, baseline) + or { r } + for _, plan in ipairs(displayedPlans) do + local displayDelta = plan.delta + if existingSocketId and not isEquippedSocket then + displayDelta = plan.delta - keepDelta + end + local pct = calculateImpactPercent(displayDelta, baseline) + local totalPoints = points + (plan.addedNodeCount or 0) + local summaryParts = { } + if variantLabel ~= "" then + t_insert(summaryParts, variantLabel) + end + if plan.resultNodeLabels and #plan.resultNodeLabels > 0 then + t_insert(summaryParts, s_format("%d node%s", #plan.resultNodeLabels, #plan.resultNodeLabels == 1 and "" or "s")) + elseif (not plan.detailText or plan.detailText == "") and variantLabel == "" then + local rIdx = jewelType.radiusIndex + local socketNode = plan.socket and treeData.nodes[plan.socket.id] + local radiusNodes = rIdx and socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[rIdx] + if radiusNodes then + local matchCount = 0 + for _, n in pairs(radiusNodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + matchCount = matchCount + 1 + end + end + if matchCount > 0 then + t_insert(summaryParts, s_format("%d match%s", matchCount, matchCount == 1 and "" or "es")) + end + end + end + local detailText = #summaryParts > 0 and t_concat(summaryParts, " | ") or (plan.detailText or "") + local detailNodeId = nil + if jewelType.isImpossibleEscape and r.variant and r.variant.keystoneName then + local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] + detailNodeId = keystoneNode and keystoneNode.id or nil + end + local action + if isEquippedSocket then + action = "keep" + elseif existingSocketId and r.replacedItemLabel then + action = "moveReplace" + elseif existingSocketId then + action = "move" + elseif r.replacedItemLabel then + action = "replace" + else + action = "new" + end + t_insert(rows, { + socketLabel = r.socket.label, + socketId = r.socket.id, + points = totalPoints, + delta = displayDelta, + pct = pct, + pctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct, + sortPctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct, + detailText = detailText, + detailNodeId = detailNodeId, + resultNodes = plan.resultNodes, + resultNodeLabels = plan.resultNodeLabels, + replacedItemLabel = r.replacedItemLabel, + storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, + itemTooltipLines = itemTooltipLines, + baseOutput = plan.baseOutput, + compareOutput = plan.compareOutput, + jewelName = jewelType.name, + jewelLimitKey = jewelLimitKey, + jewelLimit = jewelLimit, + isSocketIndependent = jewelType.isSocketIndependent, + applyRawText = applyRawText, + action = action, + tooltipHeader = jewelType.isThread and "^7Socketing this jewel and allocating the best ring plan here will give you:" + or jewelType.name == "Intuitive Leap" and "^7Socketing this jewel and allocating the best nodes here will give you:" + or jewelType.isImpossibleEscape and "^7Socketing this jewel and allocating the best keystone plan here will give you:" + or variantLabel ~= "" and "^7Socketing the best variant here will give you:" + or "^7Socketing this jewel will give you:", + }) + end + end + return rows + end + + controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, 26, 72, buttonHeight }, "Compute", function() + if computeContext then + cancelCompute("^8Compute stopped") + restoreCachedResults() + return + end + + controls.computeButton.label = "Cancel" + searchStartTime = GetTime() + setComputeProgress("^7Computing...") + local progress = makeComputeProgressTracker() + computeContext = { + co = coroutine.create(function() + local ok, err = pcall(function() + local statLabel = selectedImpactStat.label + local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil) + local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil + + if selectedJewelType.isAllJewels then + local allRows = { } + local globalBaseline + + local computeJewelTypes = { } + for _, jt in ipairs(activeJewelTypes) do + if not jt.isAllJewels and jt.hasCompute then + t_insert(computeJewelTypes, jt) + end + end + + for typeIndex, jt in ipairs(computeJewelTypes) do + local rawChild = progress:child( + (typeIndex - 1) / #computeJewelTypes, + 1 / #computeJewelTypes) + local jtName = jt.name + local function wrapProgress(base) + return { + tick = function(self, done, total, label) + base:tick(done, total, label and (jtName .. " | " .. label) or jtName) + end, + child = function(self, startFraction, spanFraction) + return wrapProgress(base:child(startFraction, spanFraction)) + end, + } + end + local typeProgress = wrapProgress(rawChild) + local equippedList = self:findEquippedJewelSockets(jt) + local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } + computeContext.removedJewels = removedJewels + local socketResults, baseline + + if jt.name == "Intuitive Leap" then + socketResults, baseline = + self:computeIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, nil, + computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + elseif jt.isThread then + socketResults, baseline = + self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, + computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + elseif jt.isImpossibleEscape then + socketResults, baseline = + self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, + jt.variants or getImpossibleEscapeVariants(), + computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + elseif jt.isSplitPersonality then + socketResults, baseline = + self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, + jt.variants or getSplitPersonalityVariants(), + typeProgress, selectedMaxPoints, selectedOccupiedMode) + elseif jt.variants and #jt.variants > 0 then + socketResults, baseline = + self:computeBestVariantSocketImpact(jewelSockets, jt.variants, selectedImpactStat, + typeProgress, selectedMaxPoints, selectedOccupiedMode) + else + socketResults, baseline = + self:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, + typeProgress, selectedMaxPoints, selectedOccupiedMode) + end + + globalBaseline = globalBaseline or baseline + self:restoreEquippedJewels(removedJewels) + computeContext.removedJewels = nil + + local typeRows = buildComputeRows(jt, socketResults, baseline, equippedList) + + -- For disconnected-passive types: keep only the best row per socket + if jt.name == "Intuitive Leap" or jt.isThread or jt.isImpossibleEscape then + local bestBySocket = { } + for _, row in ipairs(typeRows) do + local ex = bestBySocket[row.socketId] + if not ex or row.sortPctPerPoint > ex.sortPctPerPoint then + bestBySocket[row.socketId] = row + end + end + typeRows = { } + for _, row in pairs(bestBySocket) do + t_insert(typeRows, row) + end + end + + for _, row in ipairs(typeRows) do + t_insert(allRows, row) + end + end + + globalBaseline = globalBaseline or 0 + lastComputeAllRows = allRows + local displayRows = selectedAllJewelsView.id == "bestPerSocket" + and filterBestPerSocket(allRows) or allRows + controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)") + controls.statusLabel.label = formatComputeStatus("All jewels", statLabel, globalBaseline, computeMethodLabel) .. formatElapsed(searchStartTime) + saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) + else + local displayedVariants = getDisplayedVariants() + local itemLabel = selectedJewelType.name + local equippedList = self:findEquippedJewelSockets(selectedJewelType) + local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } + computeContext.removedJewels = removedJewels + local socketResults, baseline + if selectedJewelType.name == "Intuitive Leap" then + socketResults, baseline = + self:computeIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, selectedJewelVariant, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isThread then + socketResults, baseline = + self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isImpossibleEscape then + socketResults, baseline = + self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isSplitPersonality then + socketResults, baseline = + self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) + elseif displayedVariants and #displayedVariants > 0 then + if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= "ALL" then + itemLabel = selectedDreamFamily.name + end + socketResults, baseline = + self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + else + local rawText = selectedJewelType.rawText + socketResults, baseline = + self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + end + self:restoreEquippedJewels(removedJewels) + computeContext.removedJewels = nil + local rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) + controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") + controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) + saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) + end + end) + if not ok then + error(err) + end + end), + } + main.onFrameFuncs["RadiusJewelFinderCompute"] = function() + if not computeContext then + main.onFrameFuncs["RadiusJewelFinderCompute"] = nil + return + end + local res, errMsg = coroutine.resume(computeContext.co) + if not res then + cancelCompute() + controls.statusLabel.label = "^1Error: " .. tostring(errMsg) + controls.resultsList:SetMode("message", { + { text = "^1" .. tostring(errMsg) }, + }, "^1Error") + return + end + if coroutine.status(computeContext.co) == "dead" then + cancelCompute() + end + end + end) + controls.computeButton.tooltipFunc = function(tooltip) + tooltip:Clear(true) + if computeContext then + tooltip:AddLine(16, "^7Stop the current compute.") + tooltip:AddLine(16, "^8Restores the previous results.") + return + end + if selectedJewelType and selectedJewelType.isAllJewels then + tooltip:AddLine(16, "^7Rank every jewel type by the selected stat.") + else + tooltip:AddLine(16, "^7Rank compatible sockets by the selected stat.") + end + tooltip:AddLine(16, "^8Uses Stat, Max pts, and Sockets filters.") + end + controls.computeButton.shown = true + + -- Status label + controls.statusLabel = new("LabelControl"):LabelControl(TL, { 10, 54, 400, 16 }, COL_META .. "Click Find to search") + local function showAllJewelsComputePrompt() + controls.statusLabel.label = COL_META .. "Click Compute to rank all jewels" + controls.resultsList:SetMode("message", { }, COL_META .. "Click Compute to rank all jewels") + end + controls.showLegacyCheck = new("CheckBoxControl"):CheckBoxControl(TL, { 700, 54, 18 }, "Show legacy", function(state) + cancelCompute() + showLegacy = state + saveFinderState() + rebuildJewelTypeDropdown() + syncSelectedJewelTypeControls() + updatePreview() + runFind(false) + end) + + -- ── Find button ─────────────────────────────────────────────────────────── + runFind = function(makePreferred) + searchStartTime = GetTime() + if selectedJewelType and selectedJewelType.isAllJewels then + if not restoreCachedResults() then + showAllJewelsComputePrompt() + end + return + end + controls.statusLabel.label = "^7Searching..." + local ok, err = pcall(function() + local allocNodes = self.build.spec.allocNodes + local isThreadBestVariantSearch = selectedJewelType.isThread == true + local isImpossibleEscapeBestVariantSearch = selectedJewelType.isImpossibleEscape == true + local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true + local isMassiveRadiusVariant = selectedJewelVariant and selectedJewelVariant.isMassiveRadius + local radiusIndex + local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil + if isThreadBestVariantSearch then + if selectedThreadVariant then + radiusIndex = selectedThreadVariant.radiusIndex + end + elseif isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then + radiusIndex = nil + elseif isMassiveRadiusVariant then + -- data.jewelRadius has no full Massive radius; we handle it below. + elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then + radiusIndex = selectedJewelVariant.radiusIndex + else + radiusIndex = selectedJewelType.radiusIndex + end + + if not isThreadBestVariantSearch and not isImpossibleEscapeBestVariantSearch and not isSplitPersonalitySearch + and not radiusIndex and not isMassiveRadiusVariant then + return + end + + local results = { } + local impossibleEscapeBestResult + if isImpossibleEscapeBestVariantSearch then + local variants = getDisplayedVariants() or selectedJewelType.variants or { } + for _, variant in ipairs(variants) do + local keystoneNode = treeData.keystoneMap[variant.keystoneName] + local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex and keystoneNode.nodesInRadius[smallRadiusIndex] + if nodes then + local score = selectedJewelType.score(nodes, allocNodes) or 0 + local topNodes = { } + for _, n in pairs(nodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + t_insert(topNodes, { + label = n.dn or n.name or "Unknown", + nodeId = n.id, + }) + end + end + t_sort(topNodes, function(a, b) return a.label < b.label end) + local candidate = { + score = score, + topNodes = topNodes, + variant = variant, + detailText = variant.name, + } + if not impossibleEscapeBestResult + or candidate.score > impossibleEscapeBestResult.score + or (candidate.score == impossibleEscapeBestResult.score and candidate.variant.name < impossibleEscapeBestResult.variant.name) then + impossibleEscapeBestResult = candidate + end + end + end + end + for _, socket in ipairs(jewelSockets) do + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, selectedOccupiedMode) + local socketNode = treeData.nodes[socket.id] + if socketAllowed and socketNode and (socketNode.nodesInRadius or isSplitPersonalitySearch) then + if isThreadBestVariantSearch then + local bestThreadResult + for _, threadVariant in ipairs(threadVariants) do + local nodes = socketNode.nodesInRadius[threadVariant.radiusIndex] + if nodes then + local score = selectedJewelType.score(nodes, allocNodes) or 0 + local topNodes = { } + for _, n in pairs(nodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + t_insert(topNodes, { + label = n.dn or n.name or "Unknown", + nodeId = n.id, + }) + end + end + t_sort(topNodes, function(a, b) return a.label < b.label end) + local candidate = { + socket = socket, + score = score, + topNodes = topNodes, + variant = threadVariant, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + } + if not bestThreadResult + or candidate.score > bestThreadResult.score + or (candidate.score == bestThreadResult.score and candidate.variant.radiusIndex < bestThreadResult.variant.radiusIndex) then + bestThreadResult = candidate + end + end + end + if bestThreadResult then + t_insert(results, bestThreadResult) + end + elseif isImpossibleEscapeBestVariantSearch and impossibleEscapeBestResult then + t_insert(results, { + socket = socket, + score = impossibleEscapeBestResult.score, + topNodes = impossibleEscapeBestResult.topNodes, + variant = impossibleEscapeBestResult.variant, + detailText = impossibleEscapeBestResult.detailText, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + }) + elseif isSplitPersonalitySearch then + local score = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id) + t_insert(results, { + socket = socket, + score = score, + topNodes = { }, + detailText = s_format("dist to start %d", score), + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + }) + else + local nodes + if isMassiveRadiusVariant then + -- Build a temporary full Massive radius (2400). + nodes = { } + for idx, r in ipairs(data.jewelRadius) do + if r.outer <= 2400 and socketNode.nodesInRadius[idx] then + for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do + nodes[nodeId] = node + end + end + end + else + nodes = socketNode.nodesInRadius[radiusIndex] + end + + if nodes then + local scoreFn = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.score) + or selectedJewelType.score + local score = scoreFn(nodes, allocNodes) + local detailBuilder = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.detailBuilder) + or selectedJewelType.detailBuilder + local topNodes = { } + for _, n in pairs(nodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + t_insert(topNodes, { + label = n.dn or n.name or "Unknown", + nodeId = n.id, + }) + end + end + t_sort(topNodes, function(a, b) return a.label < b.label end) + t_insert(results, { + socket = socket, + score = score or 0, + topNodes = topNodes, + detailText = detailBuilder and detailBuilder(nodes, allocNodes) or nil, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + }) + end + end + end + end + + t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end) + + local equippedList = self:findEquippedJewelSockets(selectedJewelType) + local equippedSocketIds = { } + local existingSocketId + for _, entry in ipairs(equippedList) do + equippedSocketIds[entry.socketId] = true + if equippedList.atLimit then + existingSocketId = existingSocketId or entry.socketId + end + end + local rows = { } + for _, r in ipairs(results) do + local topLabels = buildNodeLabelList(r.topNodes) + local topStr = t_concat(topLabels, ", ") + if #topStr > 50 then + topStr = topStr:sub(1, 47) .. "..." + end + + local scoreLabel = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.scoreLabel) + or selectedJewelType.scoreLabel + local isEquippedSocket = equippedSocketIds[r.socket.id] + local points = isEquippedSocket and 0 + or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil }) + local scorePerPoint = points > 0 and (r.score / points) or r.score + local scorePerPointSort = points > 0 and scorePerPoint or r.score + local detailText = r.detailText + if not detailText or detailText == "" then + detailText = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel + elseif #topStr > 0 and (isThreadBestVariantSearch or isImpossibleEscapeBestVariantSearch) then + detailText = detailText .. s_format(" | %d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") + end + local detailNodeId = nil + if isImpossibleEscapeBestVariantSearch and r.variant and r.variant.keystoneName then + local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] + detailNodeId = keystoneNode and keystoneNode.id or nil + end + local action + if isEquippedSocket then + action = "keep" + elseif existingSocketId and r.replacedItemLabel then + action = "moveReplace" + elseif existingSocketId then + action = "move" + elseif r.replacedItemLabel then + action = "replace" + else + action = "new" + end + t_insert(rows, { + socketLabel = r.socket.label, + socketId = r.socket.id, + points = points, + score = r.score or 0, + scorePerPoint = scorePerPoint, + scorePerPointSort = scorePerPointSort, + variantLabel = r.variant and (r.variant.name .. " Ring") or "", + detailText = detailText, + detailNodeId = detailNodeId, + topNodes = copyTableSafe(r.topNodes, false, true), + replacedItemLabel = r.replacedItemLabel, + storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, + action = action, + applyRawText = (r.variant and r.variant.rawText) + or (selectedJewelVariant and selectedJewelVariant.rawText) + or selectedJewelType.rawText, + }) + end + controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)") + local elapsed = formatElapsed(searchStartTime) + controls.statusLabel.label = (isThreadBestVariantSearch + and s_format("^7Thread of Hope | %d | score/pt", #results) + or isImpossibleEscapeBestVariantSearch + and s_format("^7Impossible Escape | %d | score/pt", #results) + or isSplitPersonalitySearch + and s_format("^7Split Personality | %d | score/pt", #results) + or s_format("^7%d results | score/pt", #results)) .. elapsed + saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred) + if not makePreferred then + restoreCachedResults() + end + end) + if not ok then + controls.statusLabel.label = "^1Error: " .. tostring(err) + controls.resultsList:SetMode("message", { + { text = "^1" .. tostring(err) }, + }, "^1Error") + end + end + controls.findButton = new("ButtonControl"):ButtonControl(BL, { edgePadding, bottomButtonY, 100, buttonHeight }, "Find", function() + cancelCompute() + runFind(true) + end) + controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels) + controls.findButton.tooltipFunc = function(tooltip) + tooltip:Clear(true) + tooltip:AddLine(16, "^7Find sockets with matching passives for this jewel.") + tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.") + end + + applySelectedResult = function() + local idx = controls.resultsList.selIndex + local row = idx and controls.resultsList.list[idx] + if not row or not row.applyRawText then return end + + local item = new("Item"):Item("Rarity: Unique\n" .. row.applyRawText) + item:BuildModList() + self.build.itemsTab:AddItem(item, true) + + local slot = self.build.itemsTab.sockets[row.socketId] + if slot then + slot:SetSelItemId(item.id) + end + self.build.itemsTab:PopulateSlots() + self.build.buildFlag = true + end + controls.applyButton = new("ButtonControl"):ButtonControl(BL, { edgePadding + 480, bottomButtonY, 80, buttonHeight }, "Apply", applySelectedResult) + controls.applyButton.enabled = function() + local idx = controls.resultsList.selIndex + return idx and controls.resultsList.list[idx] and controls.resultsList.list[idx].applyRawText ~= nil + end + controls.applyButton.tooltipFunc = function(tooltip) + local idx = controls.resultsList.selIndex + local row = idx and controls.resultsList.list[idx] + if not row or not row.applyRawText then + tooltip:Clear(true) + tooltip:AddLine(16, "^7Select a result to apply.") + return + end + tooltip:Clear(true) + tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket")) + tooltip:AddLine(16, "^8Adds the jewel to this build.") + if row.storedUnallocatedItemLabel then + tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.") + end + tooltip:AddLine(16, "^8Double-click a result to apply it.") + end + + local function restoreFinderState() + if not finderState.jewelTypeName then + updatePreview() + if selectedJewelType and selectedJewelType.isAllJewels then + showAllJewelsComputePrompt() + end + return + end + suppressFinderStateSave = true + + if finderState.showLegacy ~= nil then + showLegacy = finderState.showLegacy + controls.showLegacyCheck.state = showLegacy + end + rebuildJewelTypeDropdown() + + local jewelTypeIndex + for i, jt in ipairs(activeJewelTypes) do + if jt.name == finderState.jewelTypeName then + jewelTypeIndex = i + break + end + end + if jewelTypeIndex then + controls.jewelTypeSelect.selIndex = jewelTypeIndex + selectedJewelType = activeJewelTypes[jewelTypeIndex] + end + + if finderState.dreamFamilyValue then + for i, option in ipairs(dreamFamilyOptions) do + if option.value == finderState.dreamFamilyValue then + selectedDreamFamily = option + controls.variantFamilySelect.selIndex = i + break + end + end + end + + syncSelectedJewelTypeControls() + + if finderState.impactStatLabel then + for i, stat in ipairs(IMPACT_STATS) do + if stat.label == finderState.impactStatLabel then + selectedImpactStat = stat + controls.impactStatSelect.selIndex = i + break + end + end + end + if finderState.maxPoints ~= nil then + selectedMaxPoints = finderState.maxPoints + controls.maxPointsEdit.buf = tostring(finderState.maxPoints) + end + if finderState.occupiedModeId then + for i, option in ipairs(OCCUPIED_SOCKET_OPTIONS) do + if option.id == finderState.occupiedModeId then + selectedOccupiedMode = option + controls.occupiedModeSelect.selIndex = i + break + end + end + end + if finderState.allJewelsViewId then + for i, option in ipairs(ALL_JEWELS_VIEW_OPTIONS) do + if option.id == finderState.allJewelsViewId then + selectedAllJewelsView = option + controls.allJewelsViewSelect.selIndex = i + break + end + end + end + if finderState.computeMethodId then + local methods = getSelectedComputeMethods() or { } + for i, method in ipairs(methods) do + if method.id == finderState.computeMethodId then + selectedComputeMethod = method + controls.computeMethodSelect.selIndex = i + break + end + end + end + if selectedJewelType and selectedJewelType.isThread and finderState.threadVariantName then + for i, variant in ipairs(threadVariants) do + if variant.name == finderState.threadVariantName then + selectedThreadVariant = variant + controls.threadVariantSelect.selIndex = i + break + end + end + elseif selectedJewelType and selectedJewelType.variants and finderState.jewelVariantName then + local variants = getDisplayedVariants() or { } + for i, variant in ipairs(variants) do + local variantName = variant.dropdownLabel or variant.name + if variantName == finderState.jewelVariantName then + selectedJewelVariant = variant + controls.jewelVariantSelect.selIndex = i + break + end + end + end + + suppressFinderStateSave = false + saveFinderState() + updatePreview() + runFind(false) + end + + -- Close button + controls.closeButton = new("ButtonControl"):ButtonControl(BR, { -edgePadding, bottomButtonY, 100, buttonHeight }, "Close", function() + cancelCompute() + main:ClosePopup() + end) + + -- Initialise preview and open popup + restoreFinderState() + local popup = main:OpenPopup(popupWidth, popupHeight, "Find Radius Jewel", controls, nil, nil, "closeButton") + local baseProcessInput = popup.ProcessInput + popup.ProcessInput = function(self, inputEvents, viewPort) + for _, event in ipairs(inputEvents) do + if event.type == "KeyDown" and event.key == "RETURN" and IsKeyDown("CTRL") then + controls.computeButton:Click() + return + end + end + baseProcessInput(self, inputEvents, viewPort) + end + return popup +end diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 842b3b4e09..a88780d206 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 @@ -403,6 +408,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) @@ -421,7 +427,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) @@ -2886,3 +2892,7 @@ function TreeTabClass:FindTimelessJewel() local panelHeight = 565 + rowSpacing + rowHeight main:OpenPopup(panelWidth, panelHeight, "Find a Timeless Jewel", controls) end + +function TreeTabClass:FindRadiusJewel() + new("RadiusJewelFinder"):RadiusJewelFinder(self):Open() +end From a10db013034fea4c2482ccf890cc0c3605db63d3 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 23 May 2026 11:59:06 +0200 Subject: [PATCH 02/52] Add radius jewel finder tests Cover socket discovery, variant handling, ranking, apply safety, occupied sockets, and state restoration for the Radius Jewel Finder. --- spec/System/TestRadiusJewelFinder_spec.lua | 1562 ++++++++++++++++++++ 1 file changed, 1562 insertions(+) create mode 100644 spec/System/TestRadiusJewelFinder_spec.lua diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua new file mode 100644 index 0000000000..494ad9e138 --- /dev/null +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -0,0 +1,1562 @@ +-- Tests for RadiusJewelFinder: buildJewelSockets, computeBestVariantSocketImpact, computeSocketImpact +-- +-- Uses OccVortex (3.13 Occultist/Vortex) as reference build. +-- Allocated jewel sockets in that build: 36634, 61419, 41263 (all occupied by jewels). +-- All other sockets are unallocated and empty. + +local occVortex = LoadModule("../spec/TestBuilds/3.13/OccVortex.lua") + +local MIGHT_OF_MEEK_RAW_TEXT = [[Might of the Meek +Crimson Jewel +Radius: Large +50% increased Effect of non-Keystone Passive Skills in Radius +Notable Passive Skills in Radius grant nothing]] + +local UNNATURAL_INSTINCT_RAW_TEXT = [[Unnatural Instinct +Viridian Jewel +Limited to: 1 +Radius: Small +Allocated Small Passive Skills in Radius grant nothing +Grants all bonuses of Unallocated Small Passive Skills in Radius]] + +local ANATOMICAL_KNOWLEDGE_RAW_TEXT = [[Anatomical Knowledge +Cobalt Jewel +Source: No longer obtainable +Radius: Large +8% increased maximum Life +Adds 1 to Maximum Life per 3 Intelligence Allocated in Radius]] + +local function buildSplitPersonalityRawText(modLine) + return table.concat({ + "Split Personality", + "Crimson Jewel", + "Variable", + "This Jewel's Socket has 25% increased effect per Allocated Passive Skill between it and your Class' starting location", + modLine, + "Corrupted", + }, "\n") +end + +local function buildImpossibleEscapeRawText(keystoneName) + return table.concat({ + "Impossible Escape", + "Viridian Jewel", + "Limited to: 1", + "Small", + "Passive Skills in radius of " .. keystoneName .. " can be allocated without being connected to your tree", + "Corrupted", + }, "\n") +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Helpers +-- ───────────────────────────────────────────────────────────────────────────── + +local function makeFinder() + return new("RadiusJewelFinder", { build = build }) +end + +local function getLargeRadiusIndex() + local map = {} + for i, r in ipairs(data.jewelRadius) do + if r.inner == 0 and not map[r.label] then map[r.label] = i end + end + return map["Large"] +end + +local function getSmallRadiusIndex() + local map = {} + for i, r in ipairs(data.jewelRadius) do + if r.inner == 0 and not map[r.label] then map[r.label] = i end + end + return map["Small"] +end + +local function makeImpossibleEscapeTestVariant() + local smallRadiusIndex = getSmallRadiusIndex() + local allocNodes = build.spec.allocNodes + for keystoneName, node in pairs(build.spec.tree.keystoneMap or {}) do + if node and node.nodesInRadius and node.nodesInRadius[smallRadiusIndex] then + -- Ensure there is at least one unallocated candidate node + local hasCandidate = false + for nodeId, n in pairs(node.nodesInRadius[smallRadiusIndex]) do + if not allocNodes[nodeId] and not n.ascendancyName + and n.type ~= "Socket" and n.type ~= "ClassStart" + and n.type ~= "AscendClassStart" and n.type ~= "Mastery" then + hasCandidate = true + break + end + end + if hasCandidate then + return { + name = keystoneName, + keystoneName = keystoneName, + rawText = buildImpossibleEscapeRawText(keystoneName), + } + end + end + end +end + +local function makeThreadVariants() + local names = { "Small", "Medium", "Large", "Very Large", "Massive" } + local variants = {} + local idx = 1 + for radiusIndex, radius in ipairs(data.jewelRadius) do + if radius.inner > 0 then + variants[#variants + 1] = { + name = names[idx] or ("Ring " .. idx), + radiusIndex = radiusIndex, + } + idx = idx + 1 + end + end + return variants +end + +local function isSorted(results, key) + for i = 2, #results do + if results[i - 1][key] < results[i][key] then return false end + end + return true +end + +local function snapshotFinderState() + local socketSelItemIds = {} + for socketId, slot in pairs(build.itemsTab.sockets) do + socketSelItemIds[socketId] = slot.selItemId + end + + local itemOrderList = {} + for i, itemId in ipairs(build.itemsTab.itemOrderList) do + itemOrderList[i] = itemId + end + + local itemCount = 0 + for _ in pairs(build.itemsTab.items) do + itemCount = itemCount + 1 + end + + return { + socketSelItemIds = socketSelItemIds, + itemOrderList = itemOrderList, + itemCount = itemCount, + jewels = copyTable(build.spec.jewels, true), + } +end + +local function assertFinderStateUnchanged(before) + local after = snapshotFinderState() + assert.are.same(before.socketSelItemIds, after.socketSelItemIds) + assert.are.same(before.itemOrderList, after.itemOrderList) + assert.are.equal(before.itemCount, after.itemCount) + assert.are.same(before.jewels, after.jewels) +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- Tests +-- ───────────────────────────────────────────────────────────────────────────── + +describe("RadiusJewelFinder #radius-jewel", function() + + before_each(function() + loadBuildFromXML(occVortex.xml, "OccVortex") + end) + + -- ── buildJewelSockets ─────────────────────────────────────────────────── + + describe("buildJewelSockets", function() + + it("returns a non-empty list", function() + local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex()) + assert.is_true(#sockets > 0, "expected at least one jewel socket") + end) + + it("each entry has id (number) and label (string)", function() + local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex()) + for _, s in ipairs(sockets) do + assert.is_number(s.id) + assert.is_string(s.label) + end + end) + + it("marks the 3 allocated sockets with # prefix", function() + local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex()) + local allocIds = { [36634] = true, [61419] = true, [41263] = true } + for _, s in ipairs(sockets) do + if allocIds[s.id] then + assert.is_true(s.label:sub(1, 2) == "# ", + "socket " .. s.id .. " should start with '# ', was: " .. s.label) + end + end + end) + + it("unallocated sockets without # prefix", function() + local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex()) + local allocIds = { [36634] = true, [61419] = true, [41263] = true } + for _, s in ipairs(sockets) do + if not allocIds[s.id] then + assert.is_false(s.label:sub(1, 2) == "# ", + "socket " .. s.id .. " should NOT start with '# '") + end + end + end) + + it("list is sorted alphabetically by label", function() + local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex()) + for i = 2, #sockets do + assert.is_true(sockets[i - 1].label <= sockets[i].label, + "sockets not sorted at index " .. i) + end + end) + + it("includes known occupied and empty sockets from the fixture build", function() + local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex()) + local seenIds = {} + for _, socket in ipairs(sockets) do + seenIds[socket.id] = true + end + + assert.is_true(seenIds[36634], "expected occupied socket 36634 to be present") + assert.is_true(seenIds[61419], "expected occupied socket 61419 to be present") + assert.is_true(seenIds[41263], "expected occupied socket 41263 to be present") + assert.is_true(seenIds[33631], "expected empty socket 33631 to be present") + end) + + end) + + describe("popup integration", function() + + it("opens the popup with expected jewel types and controls", function() + local function listLabels(list) + local labels = {} + for i, entry in ipairs(list) do + labels[i] = type(entry) == "table" and entry.label or entry + end + return labels + end + + local function tooltipTexts(control, index) + local tooltip = new("Tooltip") + control.tooltipFunc(tooltip, "DROP", index, control.list[index]) + local texts = {} + for _, line in ipairs(tooltip.lines) do + if line.text and line.text ~= "" then + texts[#texts + 1] = line.text + end + end + return texts + end + local function buttonTooltipTexts(control, ...) + local tooltip = new("Tooltip") + control.tooltipFunc(tooltip, ...) + local texts = {} + for _, line in ipairs(tooltip.lines) do + if line.text and line.text ~= "" then + texts[#texts + 1] = line.text + end + end + return texts + end + + local function findIndex(list, needle) + for i, label in ipairs(listLabels(list)) do + if label == needle then + return i + end + end + end + local function assertAlphabetical(labels, message) + for i = 2, #labels do + assert.is_true(labels[i - 1] <= labels[i], message or ("labels not sorted at index " .. i)) + end + end + + while main.popups[1] do + main:ClosePopup() + end + + build.radiusJewelFinderState = nil + local popup = makeFinder():Open() + assert.is_not_nil(popup) + assert.are.equal("Find Radius Jewel", popup.title) + local popupWidth, popupHeight = popup:GetSize() + assert.is_true(popupWidth <= 1020, "popup should fit within a 1024px-wide screen") + local popupX, popupY = popup:GetPos() + local function assertControlInsidePopup(controlName) + local control = popup.controls[controlName] + assert.is_not_nil(control, "expected control: " .. controlName) + local x, y = control:GetPos() + local width, height = control:GetSize() + assert.is_true(x >= popupX, controlName .. " should not extend past the popup left edge") + assert.is_true(y >= popupY, controlName .. " should not extend past the popup top edge") + assert.is_true(x + width <= popupX + popupWidth, controlName .. " should not extend past the popup right edge") + assert.is_true(y + height <= popupY + popupHeight, controlName .. " should not extend past the popup bottom edge") + end + for _, controlName in ipairs({ + "computeButton", + "impactStatSelect", + "previewList", + "resultDetailList", + "findButton", + "applyButton", + "closeButton", + }) do + assertControlInsidePopup(controlName) + end + for _, controlName in ipairs({ "findButton", "applyButton", "closeButton" }) do + local control = popup.controls[controlName] + local _, y = control:GetPos() + local _, height = control:GetSize() + assert.are.equal(10, popupY + popupHeight - (y + height), controlName .. " should keep the bottom action margin") + end + local computeX = popup.controls.computeButton:GetPos() + local computeWidth = popup.controls.computeButton:GetSize() + assert.are.equal(20, popupX + popupWidth - (computeX + computeWidth), "computeButton should keep the header right margin") + local closeX = popup.controls.closeButton:GetPos() + local closeWidth = popup.controls.closeButton:GetSize() + assert.are.equal(10, popupX + popupWidth - (closeX + closeWidth), "closeButton should keep the bottom right margin") + local computeTooltipTexts = buttonTooltipTexts(popup.controls.computeButton) + assert.is_true(#computeTooltipTexts > 0, "expected Compute tooltip content") + assert.is_true(computeTooltipTexts[1]:find("selected stat", 1, true) ~= nil, + "expected Compute tooltip to explain stat ranking") + assert.is_false(popup.controls.findButton:IsShown(), "Find should be hidden for All jewels") + local applyTooltipTexts = buttonTooltipTexts(popup.controls.applyButton) + assert.is_true(#applyTooltipTexts > 0, "expected Apply tooltip content") + assert.is_true(applyTooltipTexts[1]:find("Select a result", 1, true) ~= nil, + "expected Apply tooltip to explain missing selection") + assert.is_nil(popup.controls.closeButton.tooltipFunc, "Close is self-explanatory and should not need a tooltip") + local maxPointsTooltipTexts = buttonTooltipTexts(popup.controls.maxPointsEdit) + assert.is_true(#maxPointsTooltipTexts > 0, "expected Max pts tooltip content") + assert.is_true(maxPointsTooltipTexts[1]:find("total passive points", 1, true) ~= nil, + "expected Max pts tooltip to explain total point limit") + local occupiedTooltipTexts = buttonTooltipTexts(popup.controls.occupiedModeSelect, "DROP", 2, popup.controls.occupiedModeSelect.list[2]) + assert.is_true(#occupiedTooltipTexts > 0, "expected Sockets tooltip content") + assert.is_true(occupiedTooltipTexts[2]:find("socket%-specific") ~= nil, + "expected Safe occupied tooltip to explain socket-specific behavior") + assert.is_true(popup.controls.computeMethodSelect.shown, "expected Method selector for All jewels") + assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list)) + local fastMethodTooltipTexts = buttonTooltipTexts(popup.controls.computeMethodSelect, "DROP", 1, popup.controls.computeMethodSelect.list[1]) + assert.is_true(fastMethodTooltipTexts[1]:find("Intuitive Leap", 1, true) ~= nil, + "expected All jewels Method tooltip to name affected jewel types") + assert.is_true(fastMethodTooltipTexts[2]:find("independently", 1, true) ~= nil, + "expected Fast method tooltip to explain independent scoring") + local simulatedMethodTooltipTexts = buttonTooltipTexts(popup.controls.computeMethodSelect, "DROP", 2, popup.controls.computeMethodSelect.list[2]) + assert.is_true(simulatedMethodTooltipTexts[2]:find("recalculates", 1, true) ~= nil, + "expected Simulated method tooltip to explain recalculation") + popup.controls.computeMethodSelect.selFunc(2) + assert.are.equal("simulated_greedy", build.radiusJewelFinderState.computeMethodId) + popup.controls.computeMethodSelect.selFunc(1) + local allResultsViewTooltipTexts = buttonTooltipTexts(popup.controls.allJewelsViewSelect, "DROP", 1, popup.controls.allJewelsViewSelect.list[1]) + assert.is_true(allResultsViewTooltipTexts[1]:find("every compatible result", 1, true) ~= nil, + "expected All results view tooltip to explain unfiltered results") + local bestPerSocketTooltipTexts = buttonTooltipTexts(popup.controls.allJewelsViewSelect, "DROP", 2, popup.controls.allJewelsViewSelect.list[2]) + assert.is_true(bestPerSocketTooltipTexts[1]:find("one best result per socket", 1, true) ~= nil, + "expected Best per socket tooltip to explain per-socket filtering") + assert.is_true(bestPerSocketTooltipTexts[2]:find("Jewel limits", 1, true) ~= nil, + "expected Best per socket tooltip to mention jewel limits") + assert.is_true(findIndex(popup.controls.impactStatSelect.list, "Full DPS") ~= nil) + assert.is_true(findIndex(popup.controls.impactStatSelect.list, "Hit DPS") ~= nil) + assert.is_true(findIndex(popup.controls.impactStatSelect.list, "Block Chance") ~= nil) + + local hasIntuitiveLeap = false + local hasThreadOfHope = false + local hasTemperedAndTranscendent = false + local hasSplitPersonality = false + local hasImpossibleEscape = false + local hasDreamsAndNightmares = false + local jewelTypeLabels = listLabels(popup.controls.jewelTypeSelect.list) + for _, label in ipairs(popup.controls.jewelTypeSelect.list) do + if label == "Intuitive Leap" then + hasIntuitiveLeap = true + elseif label == "Thread of Hope" then + hasThreadOfHope = true + elseif label == "Tempered & Transcendent" then + hasTemperedAndTranscendent = true + elseif label == "Split Personality" then + hasSplitPersonality = true + elseif label == "Impossible Escape" then + hasImpossibleEscape = true + elseif label == "Dreams & Nightmares" then + hasDreamsAndNightmares = true + end + end + + assert.is_true(hasIntuitiveLeap, "expected Intuitive Leap in jewel type list") + assert.is_true(hasThreadOfHope, "expected Thread of Hope in jewel type list") + assert.is_true(hasTemperedAndTranscendent, "expected Tempered & Transcendent in jewel type list") + assert.is_true(hasSplitPersonality, "expected Split Personality in jewel type list") + assert.is_true(hasImpossibleEscape, "expected Impossible Escape in jewel type list") + assert.is_true(hasDreamsAndNightmares, "expected Dreams & Nightmares in jewel type list") + assertAlphabetical(jewelTypeLabels, "expected jewel types to be sorted alphabetically") + + local allJewelsIdx = findIndex(popup.controls.jewelTypeSelect.list, "All jewels") + assert.is_not_nil(allJewelsIdx, "expected All jewels in jewel type list") + local allJewelsTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, allJewelsIdx) + assert.is_true(allJewelsTooltipTexts[2]:find("%/Pt.", 1, true) ~= nil, + "expected All jewels tooltip to show %/Pt") + local doubledPercent = allJewelsTooltipTexts[2]:find("%%/Pt.", 1, true) + assert.is_nil(doubledPercent, "All jewels tooltip should not show escaped %%/Pt") + popup.controls.jewelTypeSelect.selFunc(allJewelsIdx) + local selectedResultPreview = { + { height = 16, [1] = "^7Selected Jewel" }, + { height = 16, [1] = "^8Selected result preview line" }, + } + assert.is_false(popup.controls.findButton:IsShown(), "Find should stay hidden for All jewels") + popup.controls.resultsList:SetMode("computeSocketAll", { + { + jewelName = "Selected Jewel", + socketLabel = "Socket #1", + socketId = 33631, + points = 1, + delta = 10, + pct = 10, + pctPerPoint = 10, + sortPctPerPoint = 10, + detailText = "Test detail", + itemTooltipLines = selectedResultPreview, + action = "new", + }, + }, "(no compatible sockets)") + assert.are.equal("^7Selected Jewel", popup.controls.previewList.list[1][1]) + assert.are.equal(180, popup.controls.previewList.height()) + local allJewelsDetailHover = popup.controls.resultsList:GetHoverInfo(7, popup.controls.resultsList.selValue) + assert.is_true(allJewelsDetailHover.showItemTooltip, + "All jewels Compute detail column should show jewel preview tooltip") + local allJewelsSocketHover = popup.controls.resultsList:GetHoverInfo(2, popup.controls.resultsList.selValue) + assert.is_true(allJewelsSocketHover.showViewer, + "All jewels Compute socket column should show socket preview") + popup.controls.resultsList:SetMode("message", { }, "Click Compute") + assert.are.equal("^7Evaluate every jewel type.", popup.controls.previewList.list[1][1]) + assert.are.equal(48, popup.controls.previewList.height()) + + -- Intuitive Leap: tooltip, compute method, occupied mode + local intuitiveIdx = findIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap") + assert.is_not_nil(intuitiveIdx, "expected Intuitive Leap in jewel type list") + popup.controls.jewelTypeSelect.selFunc(intuitiveIdx) + local typeTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, intuitiveIdx) + assert.is_true(#typeTooltipTexts > 0, "expected jewel type tooltip content") + assert.is_true(typeTooltipTexts[1]:find("Intuitive Leap", 1, true) ~= nil, + "expected type tooltip to describe Intuitive Leap") + assert.is_true(popup.controls.findButton:IsShown(), "Find should be shown for a single jewel type") + local findTooltipTexts = buttonTooltipTexts(popup.controls.findButton) + assert.is_true(#findTooltipTexts > 0, "expected Find tooltip content") + assert.is_true(findTooltipTexts[1]:find("matching passives", 1, true) ~= nil, + "expected Find tooltip to explain passive matching") + assert.is_true(popup.controls.computeMethodSelect.shown, "expected method selector for Intuitive Leap") + assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list)) + assert.are.same({ "Free only", "Safe occupied", "All occupied" }, listLabels(popup.controls.occupiedModeSelect.list)) + assert.are.equal("Fast", popup.controls.computeMethodSelect.list[popup.controls.computeMethodSelect.selIndex]) + + -- Dreams & Nightmares: variant tooltips + local normalDreamsIdx = findIndex(popup.controls.jewelTypeSelect.list, "Dreams & Nightmares") + assert.is_not_nil(normalDreamsIdx, "expected Dreams & Nightmares in jewel type list") + popup.controls.jewelTypeSelect.selFunc(normalDreamsIdx) + local redNightmareIdx = findIndex(popup.controls.jewelVariantSelect.list, "The Red Nightmare") + assert.is_not_nil(redNightmareIdx, "expected The Red Nightmare in variant list") + local redNightmareTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, redNightmareIdx) + assert.is_true(#redNightmareTooltipTexts > 0, "expected Red Nightmare tooltip content") + for _, text in ipairs(redNightmareTooltipTexts) do + assert.is_nil(text:find("{variant:", 1, true), "variant tooltip should not expose raw variant tags") + assert.is_nil(text:find("Selected Variant:", 1, true), "variant tooltip should not expose saved-state metadata") + end + + -- Tempered & Transcendent: type tooltip generic, variant tooltip specific + local temperedIdx = findIndex(popup.controls.jewelTypeSelect.list, "Tempered & Transcendent") + assert.is_not_nil(temperedIdx, "expected Tempered & Transcendent in jewel type list") + local temperedTypeTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, temperedIdx) + assert.is_true(#temperedTypeTooltipTexts > 0, "expected generic type tooltip content") + for _, text in ipairs(temperedTypeTooltipTexts) do + assert.is_nil(text:find("Tempered Flesh", 1, true), + "type tooltip should not include a specific variant") + end + popup.controls.jewelTypeSelect.selFunc(temperedIdx) + local variantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 1) + assert.is_true(#variantTooltipTexts > 0, "expected jewel variant tooltip content") + assert.is_true(variantTooltipTexts[1]:find("Tempered Flesh", 1, true) ~= nil, + "expected variant tooltip to describe the hovered variant") + local temperedLabels = listLabels(popup.controls.jewelVariantSelect.list) + assert.is_true(#temperedLabels > 0, "expected Tempered & Transcendent variants") + for _, label in ipairs(temperedLabels) do + assert.is_truthy(label:find("Tempered") or label:find("Transcendent"), + "variant should be Tempered or Transcendent: " .. label) + end + + -- Split Personality: unique variant labels + local splitIdx = findIndex(popup.controls.jewelTypeSelect.list, "Split Personality") + assert.is_not_nil(splitIdx, "expected Split Personality in jewel type list") + popup.controls.jewelTypeSelect.selFunc(splitIdx) + assert.is_true(popup.controls.computeButton.shown, "expected Compute for Split Personality") + local splitTypeTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, splitIdx) + for _, text in ipairs(splitTypeTooltipTexts) do + assert.is_nil(text:find("Radius:", 1, true), + "Split Personality type tooltip should not show a radius line") + end + local splitLabels = listLabels(popup.controls.jewelVariantSelect.list) + assert.is_true(#splitLabels > 0, "expected Split Personality variants") + local splitVariantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 1) + for _, text in ipairs(splitVariantTooltipTexts) do + assert.is_nil(text:find("Radius:", 1, true), + "Split Personality variant tooltip should not show a radius line") + end + local seenLabels = {} + for _, label in ipairs(splitLabels) do + assert.is_string(label) + assert.is_true(#label > 0, "variant label should not be empty") + assert.is_nil(seenLabels[label], "duplicate Split Personality variant: " .. label) + seenLabels[label] = true + end + + -- Impossible Escape: compute method + keystone variants + local impossibleIdx = findIndex(popup.controls.jewelTypeSelect.list, "Impossible Escape") + assert.is_not_nil(impossibleIdx, "expected Impossible Escape in jewel type list") + popup.controls.jewelTypeSelect.selFunc(impossibleIdx) + assert.is_true(popup.controls.computeMethodSelect.shown, "expected method selector for Impossible Escape") + assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list)) + assert.is_true(#popup.controls.jewelVariantSelect.list > 0, "expected Impossible Escape keystone variants") + + -- Thread of Hope: compute method + local threadIdx = findIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope") + assert.is_not_nil(threadIdx, "expected Thread of Hope in jewel type list") + popup.controls.jewelTypeSelect.selFunc(threadIdx) + assert.is_true(popup.controls.computeMethodSelect.shown, "expected method selector for Thread of Hope") + assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list)) + + while main.popups[1] do + main:ClosePopup() + end + assert.is_nil(main.popups[1]) + end) + + end) + + -- ── buildVariantsFromUniqueItem ────────────────────────────────────────── + + describe("buildVariantsFromUniqueItem", function() + + it("builds Light of Meaning variants with valid name and rawText", function() + local variants = makeFinder():buildVariantsFromUniqueItem("The Light of Meaning") + assert.is_true(#variants > 0, "expected at least one Light of Meaning variant") + for _, v in ipairs(variants) do + assert.is_string(v.name) + assert.is_string(v.rawText) + assert.is_true(#v.name > 0, "variant name should not be empty") + assert.is_true(#v.rawText > 0, "variant rawText should not be empty") + end + end) + + it("builds Split Personality variants with unique names", function() + local variants = makeFinder():buildVariantsFromUniqueItem("Split Personality") + assert.is_true(#variants > 0, "expected at least one Split Personality variant") + local seenNames = {} + for _, v in ipairs(variants) do + assert.is_string(v.name) + assert.is_string(v.rawText) + assert.is_nil(seenNames[v.name], "duplicate variant name: " .. v.name) + seenNames[v.name] = true + end + end) + + it("variant rawText contains Selected Variant header", function() + local variants = makeFinder():buildVariantsFromUniqueItem("The Light of Meaning") + for _, v in ipairs(variants) do + assert.is_not_nil(v.rawText:match("Selected Variant: %d+"), "rawText should contain Selected Variant: " .. v.name) + end + end) + + end) + + -- ── discoverFoulbornVariants ───────────────────────────────────────────── + + describe("discoverFoulbornVariants", function() + + it("returns empty table when no Foulborn data exists", function() + local radiusIndexByLabel = {} + for i, r in ipairs(data.jewelRadius) do + if r.inner == 0 and not radiusIndexByLabel[r.label] then + radiusIndexByLabel[r.label] = i + end + end + local variants = makeFinder():discoverFoulbornVariants("Might of the Meek", radiusIndexByLabel) + assert.is_table(variants) + -- Some data sets include Foulborn items and some do not. + local hasFoulborn = false + if data.uniques.generated then + for _, rawText in ipairs(data.uniques.generated) do + if type(rawText) == "string" and rawText:match("^Foulborn ") then + hasFoulborn = true + break + end + end + end + if not hasFoulborn then + assert.are.equal(0, #variants, "expected no Foulborn variants when no Foulborn data exists") + else + assert.is_true(#variants > 0, "expected Foulborn variants when Foulborn data exists") + for _, v in ipairs(variants) do + assert.is_string(v.name) + assert.is_string(v.rawText) + assert.is_true(v.isFoulborn) + assert.is_number(v.comboIndex) + end + end + end) + + end) + + -- ── computeBestVariantSocketImpact (The Light of Meaning) ──────────────── + + describe("computeBestVariantSocketImpact (The Light of Meaning)", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + local function getLightOfMeaningVariants() + return makeFinder():buildVariantsFromUniqueItem("The Light of Meaning") + end + + it("returns one result per socket and uses the best variant", function() + local sockets = getSockets() + local variants = getLightOfMeaningVariants() + local results, baseline = makeFinder():computeBestVariantSocketImpact(sockets, variants, "Life") + assert.is_true(#results > 0, "expected at least one result") + assert.is_true(#results <= #sockets, "should return no more than socket count") + assert.is_number(baseline) + assert.is_true(baseline > 0) + for _, r in ipairs(results) do + assert.is_not_nil(r.socket) + assert.is_not_nil(r.variant) + assert.is_string(r.variant.name) + assert.is_number(r.delta) + end + end) + + it("results are sorted by delta descending", function() + local sockets = getSockets() + local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + assert.is_true(isSorted(results, "delta"), + "results should be sorted by delta descending") + end) + + it("Life variant selected on sockets where it is better than others", function() + local sockets = getSockets() + local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + local hasLife = false + for _, r in ipairs(results) do + if r.variant.name == "Life" then hasLife = true; break end + end + assert.is_true(hasLife, "expected Life variant to be best for at least one socket") + end) + + it("restores TotalLife after compute", function() + local sockets = getSockets() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + local after = build.calcsTab.mainOutput["Life"] + assert.are.equal(before, after) + end) + + it("restores socket and item state after compute", function() + local sockets = getSockets() + local before = snapshotFinderState() + makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + assertFinderStateUnchanged(before) + end) + + it("respects occupiedMode filter", function() + local sockets = getSockets() + local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" }) + assert.is_true(#results > 0, "expected results with occupied mode 'all'") + end) + + end) + + -- ── computeSocketImpact (MoM / UI / AK) ──────────────────────────────── + + describe("computeSocketImpact", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + it("returns a table (may be empty if all sockets occupied)", function() + local results, baseline = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.is_table(results) + assert.is_number(baseline) + end) + + it("returns the current main output as baseline for the selected stat", function() + local expectedBaseline = build.calcsTab.mainOutput["Life"] + local _, baseline = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.are.equal(expectedBaseline, baseline) + end) + + it("returns at least one result for the fixture build", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.is_true(#results > 0, "expected at least one empty jewel socket result") + end) + + it("MoM: only tests empty sockets (selItemId == 0)", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + for _, r in ipairs(results) do + local slot = build.itemsTab.sockets[r.socket.id] + assert.are.equal(0, slot.selItemId, + "result socket " .. r.socket.id .. " should be empty after compute") + end + end) + + it("MoM: results sorted by delta descending", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.is_true(isSorted(results, "delta"), + "MoM socket results should be sorted by delta descending") + end) + + it("MoM: restores TotalLife after compute", function() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.are.equal(before, build.calcsTab.mainOutput["Life"]) + end) + + it("MoM: restores socket and item state after compute", function() + local before = snapshotFinderState() + makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assertFinderStateUnchanged(before) + end) + + it("UI: restores TotalLife after compute", function() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "Life") + assert.are.equal(before, build.calcsTab.mainOutput["Life"]) + end) + + it("AK: restores TotalLife after compute", function() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "Life") + assert.are.equal(before, build.calcsTab.mainOutput["Life"]) + end) + + it("respects max total points for standard compute", function() + local maxPoints = 2 + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, maxPoints) + for _, r in ipairs(results) do + assert.is_true((r.socket.pathDist or 0) <= maxPoints, + "socket " .. r.socket.id .. " used too many points") + end + end) + + it("occupied sockets (36634, 61419, 41263) are skipped", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } + for _, r in ipairs(results) do + assert.is_nil(occupiedIds[r.socket.id], + "occupied socket " .. r.socket.id .. " should not appear in results") + end + end) + + it("occupiedMode 'all' includes occupied sockets", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) + local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } + local foundOccupied = false + for _, r in ipairs(results) do + if occupiedIds[r.socket.id] then foundOccupied = true; break end + end + assert.is_true(foundOccupied, + "expected at least one occupied socket in results with mode 'all'") + end) + + it("occupiedMode 'safe' returns at least as many results as 'free'", function() + local sockets = getSockets() + local freeResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") + local safeResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "safe" }) + assert.is_true(#safeResults >= #freeResults, + "safe mode should include at least all free sockets") + end) + + it("occupiedMode 'all' returns more results than 'free' (build has occupied sockets)", function() + local sockets = getSockets() + local freeResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") + local allResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) + assert.is_true(#allResults > #freeResults, + "all mode should include more sockets than free mode (occupied sockets exist)") + end) + + it("each result has socket, value and delta fields", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + local seenSocketIds = {} + for _, r in ipairs(results) do + assert.is_not_nil(r.socket) + assert.is_number(r.socket.id) + assert.is_number(r.value) + assert.is_number(r.delta) + assert.is_nil(seenSocketIds[r.socket.id], + "duplicate socket result for socket " .. r.socket.id) + seenSocketIds[r.socket.id] = true + end + end) + + end) + + describe("disconnected passive max total points", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + it("respects max total points for Intuitive Leap", function() + local maxPoints = 4 + local results, _ = makeFinder():computeIntuitiveLeapSocketImpact( + getSockets(), "Life", false, "simulated_greedy", { }, nil, maxPoints) + for _, r in ipairs(results) do + local totalPoints = (r.socket.pathDist or 0) + (r.addedNodeCount or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. r.socket.id .. " plan used too many points") + end + end) + + it("stops at jewel-only when the socket already uses all max points", function() + local targetSocket + for _, socket in ipairs(getSockets()) do + if socket.pathDist and socket.pathDist > 0 then + targetSocket = socket + break + end + end + assert.is_not_nil(targetSocket, "expected at least one socket with path points") + local maxPoints = targetSocket.pathDist + local sockets = { targetSocket } + local fastResults = makeFinder():computeIntuitiveLeapSocketImpact( + sockets, "Life", false, "fast", { }, nil, maxPoints) + local simulatedResults = makeFinder():computeIntuitiveLeapSocketImpact( + sockets, "Life", false, "simulated_greedy", { }, nil, maxPoints) + assert.are.equal(0, fastResults[1].addedNodeCount or 0) + assert.are.equal(0, simulatedResults[1].addedNodeCount or 0) + end) + + end) + + describe("computeSplitPersonalitySocketImpact", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + local variants = { + { name = "Life", rawText = buildSplitPersonalityRawText("+5 to maximum Life") }, + { name = "Mana", rawText = buildSplitPersonalityRawText("+5 to maximum Mana") }, + } + + it("returns results and restores socket distance state", function() + local sockets = getSockets() + local before = snapshotFinderState() + local previousDistanceBySocketId = {} + for _, socket in ipairs(sockets) do + previousDistanceBySocketId[socket.id] = build.spec.nodes[socket.id] and build.spec.nodes[socket.id].distanceToClassStart + end + + local results, baseline = makeFinder():computeSplitPersonalitySocketImpact(sockets, "Life", variants) + + assert.is_true(#results > 0, "expected split personality results") + assert.is_number(baseline) + for _, result in ipairs(results) do + assert.is_not_nil(result.variant) + assert.is_number(result.splitDistance) + assert.is_string(result.detailText) + end + for _, socket in ipairs(sockets) do + local node = build.spec.nodes[socket.id] + assert.are.equal(previousDistanceBySocketId[socket.id], node and node.distanceToClassStart) + end + assertFinderStateUnchanged(before) + end) + + it("respects max total points", function() + local maxPoints = 4 + local results, _ = makeFinder():computeSplitPersonalitySocketImpact( + getSockets(), "Life", variants, nil, maxPoints) + for _, result in ipairs(results) do + local totalPoints = (result.socket.pathDist or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. result.socket.id .. " plan used too many points") + end + end) + + end) + + describe("computeImpossibleEscapeSocketImpact", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + it("returns results for both methods without changing finder state", function() + local variant = makeImpossibleEscapeTestVariant() + assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant") + local sockets = getSockets() + local before = snapshotFinderState() + + local fastResults, fastBaseline = makeFinder():computeImpossibleEscapeSocketImpact( + sockets, "Life", { variant }, "fast", { }, nil) + local simulatedResults, simulatedBaseline = makeFinder():computeImpossibleEscapeSocketImpact( + sockets, "Life", { variant }, "simulated_greedy", { }, nil) + + assert.is_true(#fastResults > 0, "expected fast Impossible Escape results") + assert.is_true(#simulatedResults > 0, "expected simulated Impossible Escape results") + assert.is_number(fastBaseline) + assert.are.equal(fastBaseline, simulatedBaseline) + assert.are.equal(variant.name, fastResults[1].variant.name) + assert.are.equal(variant.name, simulatedResults[1].variant.name) + assertFinderStateUnchanged(before) + end) + + it("respects max total points", function() + local variant = makeImpossibleEscapeTestVariant() + assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant") + local maxPoints = 4 + local results, _ = makeFinder():computeImpossibleEscapeSocketImpact( + getSockets(), "Life", { variant }, "simulated_greedy", { }, nil, maxPoints) + for _, result in ipairs(results) do + local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. result.socket.id .. " plan used too many points") + end + end) + + end) + + describe("computeThreadOfHopeSocketImpact", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + local function getTestVariants() + local threadVariants = makeThreadVariants() + return { threadVariants[1], threadVariants[2] or threadVariants[1] } + end + + local function getTestSockets(threadVariants) + for _, socket in ipairs(getSockets()) do + local slot = build.itemsTab.sockets[socket.id] + local node = build.spec.tree.nodes[socket.id] + if slot and slot.selItemId == 0 and node and node.nodesInRadius then + for _, variant in ipairs(threadVariants) do + local radiusNodes = node.nodesInRadius[variant.radiusIndex] + if radiusNodes and next(radiusNodes) then + return { socket } + end + end + end + end + return { getSockets()[1] } + end + + it("returns results for both methods without changing finder state", function() + local threadVariants = getTestVariants() + assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") + local sockets = getTestSockets(threadVariants) + local before = snapshotFinderState() + + local fastResults, fastBaseline = makeFinder():computeThreadOfHopeSocketImpact( + sockets, "Life", threadVariants, "fast", { }, nil) + local simulatedResults, simulatedBaseline = makeFinder():computeThreadOfHopeSocketImpact( + sockets, "Life", threadVariants, "simulated_greedy", { }, nil) + + assert.is_true(#fastResults > 0, "expected fast Thread of Hope results") + assert.is_true(#simulatedResults > 0, "expected simulated Thread of Hope results") + assert.is_number(fastBaseline) + assert.are.equal(fastBaseline, simulatedBaseline) + assert.is_not_nil(fastResults[1].variant) + assert.is_not_nil(simulatedResults[1].variant) + assert.is_number(fastResults[1].variant.radiusIndex) + assert.is_number(simulatedResults[1].variant.radiusIndex) + assert.is_string(fastResults[1].detailText) + assert.is_string(simulatedResults[1].detailText) + assertFinderStateUnchanged(before) + end) + + it("respects max total points", function() + local threadVariants = getTestVariants() + assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") + local maxPoints = 4 + local results, _ = makeFinder():computeThreadOfHopeSocketImpact( + getTestSockets(threadVariants), "Life", threadVariants, "simulated_greedy", { }, nil, maxPoints) + for _, result in ipairs(results) do + local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. result.socket.id .. " plan used too many points") + end + end) + + end) + + -- ── Jewel limit parsing ───────────────────────────────────────────────── + + describe("jewel limit parsing from raw text", function() + + it("parses Limited to: 1 from Impossible Escape raw text", function() + local rawText = buildImpossibleEscapeRawText("Acrobatics") + local limitKey = rawText:match("^([^\n]+)") + local limit = tonumber(rawText:match("Limited to: (%d+)")) + assert.are.equals("Impossible Escape", limitKey) + assert.are.equals(1, limit) + end) + + it("parses Limited to: 1 from Unnatural Instinct raw text", function() + local limitKey = UNNATURAL_INSTINCT_RAW_TEXT:match("^([^\n]+)") + local limit = tonumber(UNNATURAL_INSTINCT_RAW_TEXT:match("Limited to: (%d+)")) + assert.are.equals("Unnatural Instinct", limitKey) + assert.are.equals(1, limit) + end) + + it("returns nil limit for jewels without Limited to", function() + local limit = tonumber(MIGHT_OF_MEEK_RAW_TEXT:match("Limited to: (%d+)")) + assert.is_nil(limit) + end) + + end) + + -- ── filterBestPerSocket ──────────────────────────────────────────────── + + describe("filterBestPerSocket", function() + + local function makeRow(socketId, score, options) + options = options or {} + return { + socketId = socketId, + sortPctPerPoint = score, + isSocketIndependent = options.isSocketIndependent, + jewelLimitKey = options.jewelLimitKey, + jewelLimit = options.jewelLimit, + points = options.points, + name = options.name or ("row-" .. socketId), + } + end + + it("keeps one result per socket, highest score is kept", function() + local rows = { + makeRow(1, 10, { name = "A" }), + makeRow(1, 20, { name = "B" }), + makeRow(2, 15, { name = "C" }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local ids = {} + for _, r in ipairs(result) do ids[r.socketId] = r.name end + assert.are.equal("B", ids[1]) + assert.are.equal("C", ids[2]) + end) + + it("results are sorted by score descending", function() + local rows = { + makeRow(1, 5), + makeRow(2, 30), + makeRow(3, 15), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(3, #result) + assert.are.equal(2, result[1].socketId) + assert.are.equal(3, result[2].socketId) + assert.are.equal(1, result[3].socketId) + end) + + it("applies jewelLimit per jewelLimitKey", function() + local rows = { + makeRow(1, 30, { jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(2, 20, { jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(3, 10), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local ids = {} + for _, r in ipairs(result) do ids[r.socketId] = true end + assert.is_true(ids[1], "best IE should be kept") + assert.is_true(ids[3], "unlimited jewel should be kept") + assert.is_nil(ids[2], "second IE should be dropped (limit 1)") + end) + + it("allows multiple copies up to the limit", function() + local rows = { + makeRow(1, 30, { jewelLimitKey = "CF", jewelLimit = 2 }), + makeRow(2, 20, { jewelLimitKey = "CF", jewelLimit = 2 }), + makeRow(3, 10, { jewelLimitKey = "CF", jewelLimit = 2 }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + assert.are.equal(1, result[1].socketId) + assert.are.equal(2, result[2].socketId) + end) + + it("socket-dependent jewels are assigned before socket-independent", function() + -- Socket 1: dependent score 10, independent score 20 + -- The dependent should get socket 1, independent goes to socket 2 + local rows = { + makeRow(1, 10, { name = "dependent" }), + makeRow(1, 20, { name = "independent", isSocketIndependent = true }), + makeRow(2, 5, { name = "independent2", isSocketIndependent = true }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local bySocket = {} + for _, r in ipairs(result) do bySocket[r.socketId] = r.name end + -- The independent with score 20 cannot take socket 1 (dependent uses it) + -- It should go to socket 2 instead + assert.are.equal("dependent", bySocket[1]) + end) + + it("socket-independent jewels use remaining sockets after dependent allocation", function() + local rows = { + makeRow(1, 30, { name = "dependent-1" }), + makeRow(2, 25, { name = "dependent-2" }), + makeRow(1, 20, { name = "independent-1", isSocketIndependent = true }), + makeRow(2, 15, { name = "independent-2", isSocketIndependent = true }), + makeRow(3, 10, { name = "independent-3", isSocketIndependent = true }), + } + local result = makeFinder():filterBestPerSocket(rows) + local bySocket = {} + for _, r in ipairs(result) do bySocket[r.socketId] = r.name end + assert.are.equal("dependent-1", bySocket[1]) + assert.are.equal("dependent-2", bySocket[2]) + assert.are.equal("independent-3", bySocket[3]) + end) + + it("socket-independent tie-break uses fewer points", function() + local rows = { + makeRow(1, 20, { isSocketIndependent = true, points = 5 }), + makeRow(2, 20, { isSocketIndependent = true, points = 2 }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + -- Both are kept (different sockets), but fewer points should come first at equal score + -- Actually both have different sockets so both are included + -- The tie-break matters when multiple rows can use the same remaining sockets + end) + + it("socket-independent tie-break: at equal score, fewer points is kept", function() + -- Two independent jewels can use a single remaining socket + local rows = { + makeRow(1, 50, { name = "dependent" }), -- takes socket 1 + makeRow(1, 20, { name = "ie-high-points", isSocketIndependent = true, points = 8 }), + makeRow(2, 20, { name = "ie-low-points", isSocketIndependent = true, points = 2 }), + } + local result = makeFinder():filterBestPerSocket(rows) + local bySocket = {} + for _, r in ipairs(result) do bySocket[r.socketId] = r.name end + assert.are.equal("dependent", bySocket[1]) + assert.are.equal("ie-low-points", bySocket[2]) + end) + + it("limits are shared between dependent and independent jewels", function() + -- IE limited to 1: if a dependent row with same limitKey is placed first, + -- independent rows with that key are blocked + local rows = { + makeRow(1, 30, { name = "dependent-ie", jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(2, 20, { name = "independent-ie", isSocketIndependent = true, jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(3, 10, { name = "other" }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local names = {} + for _, r in ipairs(result) do names[r.name] = true end + assert.is_true(names["dependent-ie"]) + assert.is_true(names["other"]) + assert.is_nil(names["independent-ie"], "second IE should be blocked by shared limit") + end) + + it("returns empty table for empty input", function() + local result = makeFinder():filterBestPerSocket({}) + assert.are.equal(0, #result) + end) + + it("does not change the input rows table", function() + local rows = { + makeRow(2, 10), + makeRow(1, 20), + } + local originalLen = #rows + local originalFirst = rows[1] + makeFinder():filterBestPerSocket(rows) + assert.are.equal(originalLen, #rows) + assert.are.equal(originalFirst, rows[1]) + end) + + end) + + -- ── Move-aware compute helpers ───────────────────────────────────────── + + describe("move-aware compute helpers", function() + + local ALLOC_SOCKET_IDS = { 36634, 61419, 41263 } + + local function findUnallocatedSocketId() + for socketId, socketData in pairs(build.spec.nodes) do + if socketData.isJewelSocket and socketData.name ~= "Charm Socket" + and build.itemsTab.sockets[socketId] and not build.spec.allocNodes[socketId] then + return socketId + end + end + error("expected at least one unallocated jewel socket") + end + + local function equipFakeJewel(socketId, title, limit, extraItemFields) + local slot = build.itemsTab.sockets[socketId] + assert.is_not_nil(slot, "socket " .. socketId .. " should exist") + local fakeItemId = 999000 + socketId + local item = { title = title, limit = limit } + if extraItemFields then + for k, v in pairs(extraItemFields) do item[k] = v end + end + build.itemsTab.items[fakeItemId] = item + slot.selItemId = fakeItemId + build.spec.jewels[socketId] = fakeItemId + return item, fakeItemId + end + + local function getTestRadiusIndex() + return getLargeRadiusIndex() + end + + -- Find a jewel socket whose radius contains at least one unallocated node + -- with NO allocated linked nodes outside the radius ("isolated"). + -- Note: `linked` is on spec.nodes, not spec.tree.nodes. + local function findIsolatedRadiusNode(radiusIndex) + local treeData = build.spec.tree + for socketId, socketData in pairs(build.spec.nodes) do + if socketData.isJewelSocket then + local socketNode = treeData.nodes[socketId] + if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then + local radiusNodes = socketNode.nodesInRadius[radiusIndex] + for nodeId, _ in pairs(radiusNodes) do + if not build.spec.allocNodes[nodeId] then + local specNode = build.spec.nodes[nodeId] + local isolated = true + if specNode and specNode.linked then + for _, other in ipairs(specNode.linked) do + if build.spec.allocNodes[other.id] and not radiusNodes[other.id] then + isolated = false + break + end + end + end + if isolated then + return socketId, nodeId + end + end + end + end + end + end + end + + -- Find an unallocated radius node that has at least one linked node + -- OUTSIDE the radius. Returns socketId, nodeId, outsideLinkedNodeId. + -- Note: `linked` is on spec.nodes, not spec.tree.nodes. + local function findRadiusNodeWithOutsideLinkedNode(radiusIndex) + local treeData = build.spec.tree + for socketId, socketData in pairs(build.spec.nodes) do + if socketData.isJewelSocket then + local socketNode = treeData.nodes[socketId] + if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then + local radiusNodes = socketNode.nodesInRadius[radiusIndex] + for nodeId, _ in pairs(radiusNodes) do + if not build.spec.allocNodes[nodeId] then + local specNode = build.spec.nodes[nodeId] + if specNode and specNode.linked then + for _, other in ipairs(specNode.linked) do + if not radiusNodes[other.id] then + return socketId, nodeId, other.id + end + end + end + end + end + end + end + end + end + + -- ── findEquippedJewelSockets ──────────────────────────────────── + + describe("findEquippedJewelSockets", function() + + it("returns empty when no jewel of that type is equipped", function() + local result = makeFinder():findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(0, #result) + end) + + it("ignores jewels stored in unallocated sockets", function() + local socketId = findUnallocatedSocketId() + equipFakeJewel(socketId, "Thread of Hope", 1) + local finder = makeFinder() + local occupancy = finder:getSocketOccupancyInfo(socketId) + local allowed = finder:socketMatchesOccupiedMode(socketId, { id = "free" }) + + assert.is_false(occupancy.isOccupied) + assert.are.equal("Thread of Hope", occupancy.storedUnallocatedItemLabel) + assert.is_true(allowed) + assert.are.equal(7, finder:getSocketBasePoints({ id = socketId, pathDist = 7 }, occupancy)) + + local result = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(0, #result) + assert.is_false(result.atLimit) + end) + + it("returns entry but atLimit=false when equipped jewel has no limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Might of the Meek", nil) + local result = makeFinder():findEquippedJewelSockets({ name = "Might of the Meek" }) + assert.are.equal(1, #result) + assert.are.equal(ALLOC_SOCKET_IDS[1], result[1].socketId) + assert.is_false(result.atLimit) + end) + + it("returns entries with atLimit=true when limited jewel count reaches limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) + local result = makeFinder():findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(1, #result) + assert.are.equal(ALLOC_SOCKET_IDS[1], result[1].socketId) + assert.are.equal("Thread of Hope", result[1].item.title) + assert.is_true(result.atLimit) + end) + + it("returns entry but atLimit=false when equipped count is below limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2) + local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" }) + assert.are.equal(1, #result, "1 equipped < limit 2") + assert.is_false(result.atLimit) + end) + + it("returns all entries with atLimit=true when count equals limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2) + equipFakeJewel(ALLOC_SOCKET_IDS[2], "Combat Focus", 2) + local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" }) + assert.are.equal(2, #result) + assert.is_true(result.atLimit) + end) + + it("does not match jewels with different title", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) + local result = makeFinder():findEquippedJewelSockets({ name = "Impossible Escape" }) + assert.are.equal(0, #result) + end) + + end) + + it("computeSocketImpact treats jewels stored in unallocated sockets as free sockets", function() + local socketId = findUnallocatedSocketId() + equipFakeJewel(socketId, "Unnatural Instinct", 1) + local finder = makeFinder() + local results = finder:computeSocketImpact({ + { id = socketId, label = "Test socket", pathDist = 7 }, + }, MIGHT_OF_MEEK_RAW_TEXT, "Life", nil, nil, { id = "free" }) + + assert.are.equal(1, #results) + assert.is_nil(results[1].replacedItemLabel) + assert.are.equal("Unnatural Instinct", results[1].storedUnallocatedItemLabel) + end) + + -- ── findDisconnectedPassiveDependentNodes ───────────────────────────── + + describe("findDisconnectedPassiveDependentNodes", function() + + it("returns empty for items without disconnected passive properties", function() + local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], { title = "Might of the Meek" }) + assert.are.equal(0, #result) + end) + + it("returns empty for invalid socketId", function() + local item = { jewelRadiusIndex = getTestRadiusIndex() } + local result = makeFinder():findDisconnectedPassiveDependentNodes(999999, item) + assert.are.equal(0, #result) + end) + + it("returns empty when no nodes are allocated in radius", function() + local treeData = build.spec.tree + local smallRI = getTestRadiusIndex() + local testSocketId + for socketId, _ in pairs(build.itemsTab.sockets) do + local node = treeData.nodes[socketId] + if node and node.nodesInRadius and node.nodesInRadius[smallRI] + and next(node.nodesInRadius[smallRI]) then + local hasAllocated = false + for nodeId, _ in pairs(node.nodesInRadius[smallRI]) do + if build.spec.allocNodes[nodeId] then + hasAllocated = true + break + end + end + if not hasAllocated then + testSocketId = socketId + break + end + end + end + if not testSocketId then pending("no empty radius socket found") end + local item = { jewelRadiusIndex = smallRI } + local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) + assert.are.equal(0, #result) + end) + + it("returns isolated allocated nodes in radius as dependent", function() + local smallRI = getTestRadiusIndex() + local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI) + if not testSocketId then pending("no isolated radius node found") end + + build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId] + + local item = { jewelRadiusIndex = smallRI } + local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) + + assert.is_true(#result > 0, "expected at least one dependent node") + local found = false + for _, nodeId in ipairs(result) do + if nodeId == testNodeId then found = true; break end + end + assert.is_true(found, "expected node " .. testNodeId .. " in dependent nodes") + end) + + it("excludes nodes connected from outside the radius", function() + local treeData = build.spec.tree + local ri = getTestRadiusIndex() + local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri) + if not testSocketId then pending("no radius node with outside linked node found") end + + -- Allocate both the radius node and its outside linked node + build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId] + build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId] + + local item = { jewelRadiusIndex = ri } + local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) + + local found = false + for _, nodeId in ipairs(result) do + if nodeId == testNodeId then found = true; break end + end + assert.is_false(found, "node connected from outside radius should not be dependent") + end) + + it("handles IE keystoneMap path", function() + local variant = makeImpossibleEscapeTestVariant() + if not variant then pending("no IE keystone variant found") end + + local item = { + jewelData = { impossibleEscapeKeystones = { [variant.keystoneName] = true } }, + } + -- Should return empty since no extra nodes are allocated in the keystone radius + local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], item) + assert.is_table(result) + end) + + end) + + -- ── removeEquippedJewels / restoreEquippedJewels ──────────────── + + describe("removeEquippedJewels / restoreEquippedJewels", function() + + it("remove+restore keeps state identical", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1, { + jewelRadiusIndex = getTestRadiusIndex(), + }) + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(1, #equippedList) + + local beforeSlotId = build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId + local beforeSpecJewel = build.spec.jewels[ALLOC_SOCKET_IDS[1]] + local beforeAllocKeys = {} + for nodeId, _ in pairs(build.spec.allocNodes) do + beforeAllocKeys[nodeId] = true + end + + finder:removeEquippedJewels(equippedList) + finder:restoreEquippedJewels(equippedList) + + assert.are.equal(beforeSlotId, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId) + assert.are.equal(beforeSpecJewel, build.spec.jewels[ALLOC_SOCKET_IDS[1]]) + for nodeId, _ in pairs(beforeAllocKeys) do + assert.is_not_nil(build.spec.allocNodes[nodeId], + "allocNode " .. nodeId .. " should be restored") + end + end) + + it("remove clears slot.selItemId and spec.jewels", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) + + finder:removeEquippedJewels(equippedList) + + assert.are.equal(0, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId) + assert.are.equal(0, build.spec.jewels[ALLOC_SOCKET_IDS[1]]) + + finder:restoreEquippedJewels(equippedList) + end) + + it("remove clears dependent disconnected passive nodes from allocNodes", function() + local smallRI = getTestRadiusIndex() + local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI) + if not testSocketId then pending("no isolated radius node found") end + + -- Allocate the isolated node as a disconnected passive jewel would. + build.spec.allocNodes[testSocketId] = build.spec.tree.nodes[testSocketId] + build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId] + + equipFakeJewel(testSocketId, "Intuitive Leap", 1, { + jewelRadiusIndex = smallRI, + }) + + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" }) + assert.are.equal(1, #equippedList) + + finder:removeEquippedJewels(equippedList) + assert.is_nil(build.spec.allocNodes[testNodeId], + "dependent node " .. testNodeId .. " should be removed") + + finder:restoreEquippedJewels(equippedList) + assert.is_not_nil(build.spec.allocNodes[testNodeId], + "dependent node " .. testNodeId .. " should be restored") + end) + + it("remove preserves nodes connected from outside the radius", function() + local treeData = build.spec.tree + local ri = getTestRadiusIndex() + local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri) + if not testSocketId then pending("no radius node with outside linked node found") end + + build.spec.allocNodes[testSocketId] = treeData.nodes[testSocketId] + build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId] + build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId] + + equipFakeJewel(testSocketId, "Intuitive Leap", 1, { + jewelRadiusIndex = ri, + }) + + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" }) + assert.are.equal(1, #equippedList) + + finder:removeEquippedJewels(equippedList) + assert.is_not_nil(build.spec.allocNodes[testNodeId], + "connected node " .. testNodeId .. " should NOT be removed") + + finder:restoreEquippedJewels(equippedList) + end) + + end) + + end) + +end) From 09771508bf53ccddc963bcbfea554aed3abee384 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 23 May 2026 13:40:48 +0200 Subject: [PATCH 03/52] Clarify radius jewel variant selection Default variant-aware jewel types to All variants, and constrain compute/find paths when a specific variant is selected. --- spec/System/TestRadiusJewelFinder_spec.lua | 64 ++++++++++-- src/Classes/RadiusJewelFinder.lua | 114 +++++++++++++++------ 2 files changed, 135 insertions(+), 43 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 494ad9e138..744c50ce32 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -277,7 +277,8 @@ describe("RadiusJewelFinder #radius-jewel", function() end build.radiusJewelFinderState = nil - local popup = makeFinder():Open() + 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() @@ -452,6 +453,10 @@ describe("RadiusJewelFinder #radius-jewel", function() 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_false(popup.controls.findButton:IsShown(), + "Find should be hidden while all variants are selected") 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) @@ -460,6 +465,9 @@ describe("RadiusJewelFinder #radius-jewel", function() 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") -- Tempered & Transcendent: type tooltip generic, variant tooltip specific local temperedIdx = findIndex(popup.controls.jewelTypeSelect.list, "Tempered & Transcendent") @@ -471,15 +479,22 @@ describe("RadiusJewelFinder #radius-jewel", function() "type tooltip should not include a specific variant") end popup.controls.jewelTypeSelect.selFunc(temperedIdx) - local variantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 1) + 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 _, label in ipairs(temperedLabels) do - assert.is_truthy(label:find("Tempered") or label:find("Transcendent"), - "variant should be Tempered or Transcendent: " .. label) + 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 @@ -494,17 +509,20 @@ describe("RadiusJewelFinder #radius-jewel", function() end local splitLabels = listLabels(popup.controls.jewelVariantSelect.list) assert.is_true(#splitLabels > 0, "expected Split Personality variants") - local splitVariantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 1) + 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 _, label in ipairs(splitLabels) do + for i, label in ipairs(splitLabels) do assert.is_string(label) assert.is_true(#label > 0, "variant label should not be empty") - assert.is_nil(seenLabels[label], "duplicate Split Personality variant: " .. label) - seenLabels[label] = true + 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 @@ -514,6 +532,34 @@ describe("RadiusJewelFinder #radius-jewel", function() 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.computeImpossibleEscapeSocketImpact = function(_, _, _, variants) + capturedVariants = 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") diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index e9537a6959..a005a7acd3 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -1056,6 +1056,7 @@ function RadiusJewelFinderClass:Open() { id = "all", label = "All results" }, { id = "bestPerSocket", label = "Best per socket" }, } + local ALL_VARIANTS_LABEL = "All variants" local allJewelsViewLabels = { } for _, v in ipairs(ALL_JEWELS_VIEW_OPTIONS) do t_insert(allJewelsViewLabels, v.label) end local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1] @@ -1218,39 +1219,56 @@ function RadiusJewelFinderClass:Open() end return variants end - return selectedJewelType.variants -end + return selectedJewelType.variants + end -local function buildPreviewLinesForJewelType(jewelType, previewVariantOverride) - if not jewelType then + local function getSelectedVariants() + local variants = getDisplayedVariants() + if not variants then + return nil + end + if selectedJewelVariant then + return { selectedJewelVariant } + end + return variants + end + + local function buildPreviewLinesForJewelType(jewelType, previewVariantOverride) + if not jewelType then return nil end local fn = jewelPreviewFn[jewelType.name] if not fn then return nil + end + local selectedTypeMatches = selectedJewelType and selectedJewelType.name == jewelType.name + if jewelType.isThread then + local threadVariant = previewVariantOverride or selectedThreadVariant + return fn(threadVariant and threadVariant.name) + elseif jewelType.variants then + local previewVariant = previewVariantOverride + if not previewVariant then + previewVariant = selectedTypeMatches and selectedJewelVariant or nil + end + if not previewVariant and not selectedTypeMatches then + previewVariant = jewelType.variants[1] + end + return fn(previewVariant) + end + return fn() end - local selectedTypeMatches = selectedJewelType and selectedJewelType.name == jewelType.name - if jewelType.isThread then - local threadVariant = previewVariantOverride or selectedThreadVariant - return fn(threadVariant and threadVariant.name) - elseif jewelType.variants then - local previewVariant = previewVariantOverride or ((selectedTypeMatches and selectedJewelVariant) or jewelType.variants[1]) - return fn(previewVariant) - end - return fn() -end -local function addPreviewLinesToTooltip(tooltip, lines) - if type(lines) ~= "table" then - return - end - tooltip:Clear(true) - for _, line in ipairs(lines) do - tooltip:AddLine(line.height or 16, line[1], line.font) + local function addPreviewLinesToTooltip(tooltip, lines) + if type(lines) ~= "table" then + return + end + tooltip:Clear(true) + for _, line in ipairs(lines) do + tooltip:AddLine(line.height or 16, line[1], line.font) + end end -end -local function buildGenericTypeTooltipLinesForJewelType(jewelType) + local function buildGenericTypeTooltipLinesForJewelType(jewelType) if not jewelType then return nil end @@ -1314,26 +1332,32 @@ end 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 + varIdx = i + 1 + matchedVariant = variant break end end else - varIdx = controls.jewelVariantSelect.selIndex or 1 + varIdx = 1 + end + if not matchedVariant then + selectedJewelVariant = nil end - if varIdx > #variants then + if varIdx > #variantNames then varIdx = 1 + selectedJewelVariant = nil end controls.jewelVariantSelect.selIndex = varIdx - selectedJewelVariant = variants[varIdx] saveFinderState() end @@ -1688,9 +1712,13 @@ end cancelCompute() local variants = getDisplayedVariants() if variants then - selectedJewelVariant = variants[idx] + selectedJewelVariant = idx == 1 and nil or variants[idx - 1] saveFinderState() updatePreview() + if controls.findButton then + controls.findButton.shown = not (selectedJewelType and selectedJewelType.variants + and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) + end end end) controls.jewelVariantSelect.enableDroppedWidth = true @@ -1778,6 +1806,9 @@ end else selectedJewelVariant = nil end + if controls.findButton and hasVariants and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape then + controls.findButton.shown = false + end if hasComputeMethods then syncComputeMethodSelect(selectedJewelType.computeMethods) end @@ -1805,11 +1836,22 @@ end end controls.jewelVariantSelect.tooltipFunc = function(tooltip, mode, index) local variants = getDisplayedVariants() - local variant = variants and variants[index] - if not selectedJewelType or not variant then + if not selectedJewelType or not variants then return end - addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant)) + if not index then + addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType)) + return + end + if index == 1 then + addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(selectedJewelType)) + tooltip:AddLine(16, "^8Compute compares every displayed variant.") + return + end + local variant = variants[index - 1] + if variant then + addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant)) + end end controls.threadVariantSelect.tooltipFunc = function(tooltip, mode, index) local variant = threadVariants[index] @@ -2096,7 +2138,7 @@ end controls.statusLabel.label = formatComputeStatus("All jewels", statLabel, globalBaseline, computeMethodLabel) .. formatElapsed(searchStartTime) saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) else - local displayedVariants = getDisplayedVariants() + local displayedVariants = getSelectedVariants() local itemLabel = selectedJewelType.name local equippedList = self:findEquippedJewelSockets(selectedJewelType) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } @@ -2229,7 +2271,7 @@ end local results = { } local impossibleEscapeBestResult if isImpossibleEscapeBestVariantSearch then - local variants = getDisplayedVariants() or selectedJewelType.variants or { } + local variants = getSelectedVariants() or selectedJewelType.variants or { } for _, variant in ipairs(variants) do local keystoneNode = treeData.keystoneMap[variant.keystoneName] local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex and keystoneNode.nodesInRadius[smallRadiusIndex] @@ -2595,12 +2637,16 @@ end local variantName = variant.dropdownLabel or variant.name if variantName == finderState.jewelVariantName then selectedJewelVariant = variant - controls.jewelVariantSelect.selIndex = i + controls.jewelVariantSelect.selIndex = i + 1 break end end end + if controls.findButton and selectedJewelType and selectedJewelType.variants then + controls.findButton.shown = not (not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) + end + suppressFinderStateSave = false saveFinderState() updatePreview() From 200f9359fe6bbd25038f6c87d7e84cfd3b802898 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 23 May 2026 14:32:01 +0200 Subject: [PATCH 04/52] Derive radius jewel data from item text --- spec/System/TestRadiusJewelFinder_spec.lua | 53 ++- src/Classes/RadiusJewelData.lua | 449 +++++++-------------- src/Classes/RadiusJewelFinder.lua | 6 +- 3 files changed, 188 insertions(+), 320 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 744c50ce32..82e9b30a52 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -5,6 +5,7 @@ -- All other sockets are unallocated and empty. local occVortex = LoadModule("../spec/TestBuilds/3.13/OccVortex.lua") +local RadiusJewelData = LoadModule("Classes/RadiusJewelData") local MIGHT_OF_MEEK_RAW_TEXT = [[Might of the Meek Crimson Jewel @@ -72,6 +73,11 @@ local function getSmallRadiusIndex() return map["Small"] end +local function getRadiusIndexFromRawText(rawText) + local item = new("Item", "Rarity: Unique\n" .. rawText) + return item.jewelRadiusIndex +end + local function makeImpossibleEscapeTestVariant() local smallRadiusIndex = getSmallRadiusIndex() local allocNodes = build.spec.allocNodes @@ -588,6 +594,8 @@ describe("RadiusJewelFinder #radius-jewel", function() 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) @@ -612,18 +620,49 @@ describe("RadiusJewelFinder #radius-jewel", function() end) + -- ── buildJewelTypes ────────────────────────────────────────────────────── + + describe("buildJewelTypes", function() + + 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) + + end) + -- ── discoverFoulbornVariants ───────────────────────────────────────────── describe("discoverFoulbornVariants", function() it("returns empty table when no Foulborn data exists", function() - local radiusIndexByLabel = {} - for i, r in ipairs(data.jewelRadius) do - if r.inner == 0 and not radiusIndexByLabel[r.label] then - radiusIndexByLabel[r.label] = i - end - end - local variants = makeFinder():discoverFoulbornVariants("Might of the Meek", radiusIndexByLabel) + local variants = makeFinder():discoverFoulbornVariants("Might of the Meek") assert.is_table(variants) -- Some data sets include Foulborn items and some do not. local hasFoulborn = false diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 7838ce41ee..af126af8e2 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -117,6 +117,27 @@ 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", "Rarity: Unique\n" .. rawText) + return item.jewelRadiusIndex +end + +local function getUniqueRadiusIndex(name, baseName) + return getRadiusIndexFromRawText(mustGetCurrentUniqueRawText(name, baseName)) +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 @@ -135,6 +156,7 @@ local function buildVariantsFromUniqueItem(uniqueName, baseName) t_insert(variants, { name = variantName, rawText = rawText, + radiusIndex = getRadiusIndexFromRawText(rawText), }) end end @@ -144,7 +166,7 @@ end M.buildVariantsFromUniqueItem = buildVariantsFromUniqueItem -local function discoverFoulbornVariants(uniqueName, radiusIndexByLabel) +local function discoverFoulbornVariants(uniqueName) local variants = { } local generated = data.uniques.generated if not generated then return variants end @@ -152,12 +174,10 @@ local function discoverFoulbornVariants(uniqueName, radiusIndexByLabel) for _, rawText in ipairs(generated) do local comboIndex = rawText:match("^Foulborn " .. escapedName .. " (%d+)\n") if comboIndex then - local radiusLabel = rawText:match("\nRadius: (%a+)") - local radiusIndex = radiusLabel and radiusIndexByLabel[radiusLabel] t_insert(variants, { name = "Foulborn " .. comboIndex, rawText = rawText, - radiusIndex = radiusIndex, + radiusIndex = getRadiusIndexFromRawText(rawText), isFoulborn = true, comboIndex = tonumber(comboIndex), }) @@ -360,11 +380,13 @@ local function buildImpossibleEscapeVariants() 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 = mustGetUniqueVariantRawText("Impossible Escape", name), + rawText = variantRawText, + radiusIndex = getRadiusIndexFromRawText(variantRawText), scoreLabel = "unalloc notable/keystone near keystone", }) end @@ -380,6 +402,7 @@ local function makeTemperedVariant(name, rawText, attribute, includeAllocated, i 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"), @@ -545,316 +568,115 @@ local function previewFromRawText(rawText, displayName, extraPreviewMeta) return lines end -local jewelPreviewFn -- set below; group preview functions read it from this outer local -jewelPreviewFn = { +local function previewUnique(uniqueName, displayName, baseName) + return previewFromRawText(mustGetCurrentUniqueRawText(uniqueName, baseName), displayName) +end + +local function previewVariant(variant, displayName) + if variant and variant.rawText then + return previewFromRawText(variant.rawText, displayName or variant.name) + end + return nil +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 previewVariantOrGroup(groupName, variant) + return previewVariant(variant) or previewFinderGroup(groupName) +end + +local function previewThreadOfHope(ringName) + local rawText = mustGetUniqueRawText("Thread of Hope") + local displayName + if ringName then + local item = new("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 + end + return previewFromRawText(rawText, displayName) +end + +local jewelPreviewFn = { ["The Light of Meaning"] = function(variant) if variant and variant.rawText then return previewFromRawText(variant.rawText, "The Light of Meaning (" .. variant.name .. ")") end - local lines = previewHeader("The Light of Meaning", "Prismatic Jewel", "Large", - { "Limited to: 1", "Source: King of The Mists" }) - for _, v in ipairs(getLightOfMeaningVariants()) do - t_insert(lines, { height = 14, [1] = COL_META .. " " .. v.name }) - end - return lines + return previewFinderGroup("The Light of Meaning") end, ["Might of the Meek"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText) - end - local lines = previewHeader("Might of the Meek", "Crimson Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "50% increased Effect of non-Keystone" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Notable Passive Skills in Radius grant nothing" }) - return lines + return previewVariant(variant) or previewUnique("Might of the Meek") end, ["Unnatural Instinct"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText) - end - local lines = previewHeader("Unnatural Instinct", "Viridian Jewel", "Small", - { "Limited to: 1" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Allocated Small Passive Skills in" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Radius grant nothing" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Grants all bonuses of Unallocated" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Small Passive Skills in Radius" }) - return lines + return previewVariant(variant) or previewUnique("Unnatural Instinct") end, ["Inspired Learning"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText) - end - local lines = previewHeader("Inspired Learning", "Crimson Jewel", "Small") - t_insert(lines, { height = 16, [1] = COL_MOD .. "With 4 Notables Allocated in Radius," }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "When you Kill a Rare monster, you gain" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "1 of its Modifiers for 20 seconds" }) - return lines + return previewVariant(variant) or previewUnique("Inspired Learning") end, ["Anatomical Knowledge"] = function() - local lines = previewHeader("Anatomical Knowledge", "Cobalt Jewel", "Large", - { "Source: No longer obtainable" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "(6-8)% increased maximum Life" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Adds 1 to Maximum Life per 3" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence Allocated in Radius" }) - return lines + return previewUnique("Anatomical Knowledge") end, ["Lioneye's Fall"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText) - end - local lines = previewHeader("Lioneye's Fall", "Viridian Jewel", "Medium") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Melee and Melee Weapon Type modifiers" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "in Radius are Transformed to Bow Modifiers" }) - return lines + return previewVariant(variant) or previewUnique("Lioneye's Fall") end, ["Intuitive Leap"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText) - end - local lines = previewHeader("Intuitive Leap", "Viridian Jewel", "Small") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives in Radius can be Allocated" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "without being connected to your tree" }) - return lines + return previewVariant(variant) or previewUnique("Intuitive Leap") end, ["Tempered & Transcendent"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText, variant.name) - end - local lines = previewHeader("Tempered & Transcendent", "Unique Jewel", "Medium") - t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Flesh / Transcendent Flesh" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Mind / Transcendent Mind" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Spirit / Transcendent Spirit" }) - return lines + return previewVariantOrGroup("Tempered & Transcendent", variant) end, ["Split Personality"] = function(variant) if variant and variant.rawText then return previewFromRawText(variant.rawText, "Split Personality (" .. variant.name .. ")") end - local lines = previewHeader("Split Personality", "Crimson Jewel", nil, - { "Limited to: 2", "Source: Drops from the Simulacrum Encounter" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Socket effect scales with distance to class start" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Variants: Strength, Dexterity, Intelligence, Life" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Mana, Energy Shield, Armour, Evasion, Accuracy" }) - return lines + return previewFinderGroup("Split Personality") end, ["Impossible Escape"] = function(variant) if variant and variant.rawText then return previewFromRawText(variant.rawText, "Impossible Escape (" .. variant.name .. ")") end - local lines = previewHeader("Impossible Escape", "Viridian Jewel", "Small", - { "Limited to: 1", "Source: Drops from The Maven (Uber)" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in radius of the chosen" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Keystone can be allocated without connection" }) - return lines - end, - - ["Energy From Within"] = function() - local lines = previewHeader("Energy From Within", "Cobalt Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "3% increased maximum Energy Shield" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Life mods in Radius apply to Energy Shield" }) - return lines - end, - - ["Healthy Mind"] = function() - local lines = previewHeader("Healthy Mind", "Cobalt Jewel", "Large", - { "Limited to: 1" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "15% increased maximum Mana" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Life mods in Radius apply to Mana at 200%" }) - return lines - end, - - ["Energised Armour"] = function() - local lines = previewHeader("Energised Armour", "Crimson Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "15% increased Armour" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "ES mods in Radius apply to Armour at 200%" }) - return lines - end, - - ["Brute Force Solution"] = function() - local lines = previewHeader("Brute Force Solution", "Cobalt Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Intelligence" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Strength from Passives -> Intelligence" }) - return lines - end, - - ["Careful Planning"] = function() - local lines = previewHeader("Careful Planning", "Viridian Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Dexterity" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence from Passives -> Dexterity" }) - return lines - end, - - ["Efficient Training"] = function() - local lines = previewHeader("Efficient Training", "Crimson Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Strength" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence from Passives -> Strength" }) - return lines - end, - - ["Fertile Mind"] = function() - local lines = previewHeader("Fertile Mind", "Cobalt Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Intelligence" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Dexterity from Passives -> Intelligence" }) - return lines - end, - - ["Fluid Motion"] = function() - local lines = previewHeader("Fluid Motion", "Viridian Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Dexterity" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Strength from Passives -> Dexterity" }) - return lines - end, - - ["Inertia"] = function() - local lines = previewHeader("Inertia", "Crimson Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Strength" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Dexterity from Passives -> Strength" }) - return lines - end, - - ["Combat Focus (Crimson)"] = function() - local lines = previewHeader("Combat Focus", "Crimson Jewel", "Medium", - { "Limited to: 2", "Source: Vendor Recipe" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Cold" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Str+Int in Radius" }) - return lines - end, - - ["Combat Focus (Cobalt)"] = function() - local lines = previewHeader("Combat Focus", "Cobalt Jewel", "Medium", - { "Limited to: 2", "Source: Vendor Recipe" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Fire" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Int+Dex in Radius" }) - return lines - end, - - ["Combat Focus (Viridian)"] = function() - local lines = previewHeader("Combat Focus", "Viridian Jewel", "Medium", - { "Limited to: 2", "Source: Vendor Recipe" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Lightning" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Dex+Str in Radius" }) - return lines + return previewFinderGroup("Impossible Escape") end, ["Attribute Conversion"] = function(variant) - if variant and jewelPreviewFn[variant.name] then - return jewelPreviewFn[variant.name]() - end - local lines = previewHeader("Attribute Conversion", "Corrupted Jewel", "Large") - t_insert(lines, { height = 14, [1] = COL_META .. "Brute Force Solution: Str -> Int" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Careful Planning: Int -> Dex" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Efficient Training: Int -> Str" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Fertile Mind: Dex -> Int" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Fluid Motion: Str -> Dex" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Inertia: Dex -> Str" }) - return lines + return previewVariantOrGroup("Attribute Conversion", variant) end, ["Stat Conversion"] = function(variant) - if variant and jewelPreviewFn[variant.name] then - return jewelPreviewFn[variant.name]() - end - local lines = previewHeader("Stat Conversion", "Corrupted Jewel", "Large") - t_insert(lines, { height = 14, [1] = COL_META .. "Energy From Within: Life -> Energy Shield" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Healthy Mind: Life -> Mana (200%)" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Energised Armour: ES -> Armour (200%)" }) - return lines + return previewVariantOrGroup("Stat Conversion", variant) end, ["Combat Focus"] = function(variant) - if variant and jewelPreviewFn[variant.name] then - return jewelPreviewFn[variant.name]() - end - local lines = previewHeader("Combat Focus", "Jewel", "Medium", - { "Limited to: 2", "Source: Vendor Recipe" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Crimson: lose Cold (Str+Int >= 40)" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Cobalt: lose Fire (Int+Dex >= 40)" }) - t_insert(lines, { height = 14, [1] = COL_META .. "Viridian: lose Lightning (Dex+Str >= 40)" }) - return lines + return previewVariantOrGroup("Combat Focus", variant) end, ["Dreams & Nightmares"] = function(variant) - if variant and variant.rawText then - local extraPreviewMeta = nil - if variant.family then - extraPreviewMeta = { "Family: " .. variant.family:gsub("^The ", "") } - end - return previewFromRawText(variant.rawText, variant.name, extraPreviewMeta) - end - local lines = previewHeader("Dreams & Nightmares", "Unique Jewel", "Large") - t_insert(lines, { height = 14, [1] = COL_META .. "The Red Dream: Fire Res -> Endurance on Kill" }) - t_insert(lines, { height = 14, [1] = COL_META .. "The Red Nightmare: Fire Res -> Block" }) - t_insert(lines, { height = 14, [1] = COL_META .. "The Green Dream: Cold Res -> Frenzy on Kill" }) - t_insert(lines, { height = 14, [1] = COL_META .. "The Green Nightmare: Cold Res -> Suppress" }) - t_insert(lines, { height = 14, [1] = COL_META .. "The Blue Dream: Lightning Res -> Power on Kill" }) - t_insert(lines, { height = 14, [1] = COL_META .. "The Blue Nightmare: Lightning Res -> Spell Block" }) - return lines - end, - - ["The Red Dream"] = function() - local lines = previewHeader("The Red Dream", "Crimson Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Fire/All Res in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Endurance Charge on Kill" }) - return lines - end, - - ["The Red Nightmare"] = function() - local lines = previewHeader("The Red Nightmare", "Crimson Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Fire/All Res in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Chance to Block at 50%" }) - return lines - end, - - ["The Green Dream"] = function() - local lines = previewHeader("The Green Dream", "Viridian Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Cold/All Res in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Frenzy Charge on Kill" }) - return lines - end, - - ["The Green Nightmare"] = function() - local lines = previewHeader("The Green Nightmare", "Viridian Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Cold/All Res in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Chance to Suppress at 70%" }) - return lines - end, - - ["The Blue Dream"] = function() - local lines = previewHeader("The Blue Dream", "Cobalt Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Lightning/All Res in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Power Charge on Kill" }) - return lines - end, - - ["The Blue Nightmare"] = function() - local lines = previewHeader("The Blue Nightmare", "Cobalt Jewel", "Large") - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Lightning/All Res in Radius" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Spell Block at 50%" }) - return lines + return previewVariantOrGroup("Dreams & Nightmares", variant) end, ["Thread of Hope"] = function(ringName) - local ring = ringName or "?" - local lines = previewHeader("Thread of Hope", "Crimson Jewel", "Variable", - { "Source: Drops from Sirus, Awakener of Worlds" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Only affects Passives in " .. ring .. " Ring" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in Radius can be Allocated" }) - t_insert(lines, { height = 16, [1] = COL_MOD .. "without being connected to your tree" }) - t_insert(lines, { height = 6, [1] = "" }) - t_insert(lines, { height = 16, [1] = COL_NEG .. "-(20-10)% to all Elemental Resistances" }) - return lines + return previewThreadOfHope(ringName) end, } @@ -864,10 +686,10 @@ M.jewelPreviewFn = jewelPreviewFn -- Jewel type definitions -- ───────────────────────────────────────────────────────────────────────────── -function M.buildJewelTypes(radiusIndexByLabel) +function M.buildJewelTypes() local mightOfTheMeek = { name = "Might of the Meek", - radiusIndex = radiusIndexByLabel["Large"], + radiusIndex = getUniqueRadiusIndex("Might of the Meek"), scoreLabel = "alloc small passives", hasCompute = true, rawText = mustGetUniqueRawText("Might of the Meek"), @@ -881,12 +703,12 @@ function M.buildJewelTypes(radiusIndexByLabel) return s end, } - appendFoulbornVariants(mightOfTheMeek, discoverFoulbornVariants("Might of the Meek", radiusIndexByLabel)) + appendFoulbornVariants(mightOfTheMeek, discoverFoulbornVariants("Might of the Meek")) local inspiredLearning = { name = "Inspired Learning", hasCompute = true, - radiusIndex = radiusIndexByLabel["Small"], + radiusIndex = getUniqueRadiusIndex("Inspired Learning"), scoreLabel = "alloc notables", rawText = mustGetUniqueRawText("Inspired Learning"), score = function(nodes, allocNodes) @@ -900,14 +722,14 @@ function M.buildJewelTypes(radiusIndexByLabel) end, } do - local foulbornVariants = discoverFoulbornVariants("Inspired Learning", radiusIndexByLabel) + local foulbornVariants = discoverFoulbornVariants("Inspired Learning") for _, variant in ipairs(foulbornVariants) do addInspiredLearningFoulbornFields(variant) end appendFoulbornVariants(inspiredLearning, foulbornVariants) end local unnaturalInstinct = { name = "Unnatural Instinct", - radiusIndex = radiusIndexByLabel["Small"], + radiusIndex = getUniqueRadiusIndex("Unnatural Instinct"), scoreLabel = "unalloc small - alloc small", hasCompute = true, rawText = mustGetUniqueRawText("Unnatural Instinct"), @@ -923,24 +745,24 @@ function M.buildJewelTypes(radiusIndexByLabel) end, } do - local foulbornVariants = discoverFoulbornVariants("Unnatural Instinct", radiusIndexByLabel) + local foulbornVariants = discoverFoulbornVariants("Unnatural Instinct") for _, variant in ipairs(foulbornVariants) do addUnnaturalInstinctFoulbornFields(variant) end appendFoulbornVariants(unnaturalInstinct, foulbornVariants) end local lioneyesFall = { name = "Lioneye's Fall", - radiusIndex = radiusIndexByLabel["Medium"], + radiusIndex = getUniqueRadiusIndex("Lioneye's Fall"), scoreLabel = "alloc passives", hasCompute = true, rawText = mustGetUniqueRawText("Lioneye's Fall"), score = scoreAllocPassives, } - appendFoulbornVariants(lioneyesFall, discoverFoulbornVariants("Lioneye's Fall", radiusIndexByLabel)) + appendFoulbornVariants(lioneyesFall, discoverFoulbornVariants("Lioneye's Fall")) local intuitiveLeap = { name = "Intuitive Leap", - radiusIndex = radiusIndexByLabel["Small"], + radiusIndex = getUniqueRadiusIndex("Intuitive Leap"), scoreLabel = "unalloc passives", hasCompute = true, computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, @@ -950,27 +772,29 @@ function M.buildJewelTypes(radiusIndexByLabel) end, } do - local foulbornVariants = discoverFoulbornVariants("Intuitive Leap", radiusIndexByLabel) + local foulbornVariants = discoverFoulbornVariants("Intuitive Leap") for _, variant in ipairs(foulbornVariants) do addIntuitiveLeapFoulbornFields(variant) end appendFoulbornVariants(intuitiveLeap, foulbornVariants) end local dreamsNightmaresFamilies = { - { name = "The Red Dream", baseName = "Crimson Jewel" }, - { name = "The Red Nightmare", baseName = "Crimson Jewel" }, - { name = "The Green Dream", baseName = "Viridian Jewel" }, - { name = "The Green Nightmare", baseName = "Viridian Jewel" }, - { name = "The Blue Dream", baseName = "Cobalt Jewel" }, - { name = "The Blue Nightmare", baseName = "Cobalt Jewel" }, + { 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 _, familyInfo in ipairs(dreamsNightmaresFamilies) do + local rawText = mustGetCurrentUniqueRawText(familyInfo.name) t_insert(dreamsVariants, { name = familyInfo.name, family = familyInfo.name, - rawText = mustGetCurrentUniqueRawText(familyInfo.name), + rawText = rawText, + radiusIndex = getRadiusIndexFromRawText(rawText), }) - local foulbornVariants = discoverFoulbornVariants(familyInfo.name, radiusIndexByLabel) + local foulbornVariants = discoverFoulbornVariants(familyInfo.name) for _, variant in ipairs(foulbornVariants) do variant.family = familyInfo.name variant.name = familyInfo.name .. " (" .. variant.name .. ")" @@ -978,22 +802,42 @@ function M.buildJewelTypes(radiusIndexByLabel) 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 jewelTypes = { } t_insert(jewelTypes, { name = "The Light of Meaning", - limit = 1, - radiusIndex = radiusIndexByLabel["Large"], + radiusIndex = lightOfMeaningVariants[1] and lightOfMeaningVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, score = scoreAllocPassives, - variants = getLightOfMeaningVariants(), + variants = lightOfMeaningVariants, }) t_insert(jewelTypes, mightOfTheMeek) t_insert(jewelTypes, unnaturalInstinct) t_insert(jewelTypes, inspiredLearning) t_insert(jewelTypes, { name = "Anatomical Knowledge", - radiusIndex = radiusIndexByLabel["Large"], + radiusIndex = getUniqueRadiusIndex("Anatomical Knowledge"), scoreLabel = "alloc passives", hasCompute = true, isLegacy = true, @@ -1002,13 +846,13 @@ function M.buildJewelTypes(radiusIndexByLabel) }) t_insert(jewelTypes, { name = "Tempered & Transcendent", - radiusIndex = radiusIndexByLabel["Medium"], + radiusIndex = temperedTranscendentVariants[1] and temperedTranscendentVariants[1].radiusIndex, scoreLabel = "attr in radius", hasCompute = true, score = function(nodes, allocNodes) return scoreRadiusAttributes(nodes, allocNodes, "Str", true, false) end, - variants = M.getTemperedTranscendentVariants(), + variants = temperedTranscendentVariants, }) t_insert(jewelTypes, lioneyesFall) t_insert(jewelTypes, intuitiveLeap) @@ -1034,46 +878,31 @@ function M.buildJewelTypes(radiusIndexByLabel) }) t_insert(jewelTypes, { name = "Stat Conversion", - radiusIndex = radiusIndexByLabel["Large"], + radiusIndex = statConversionVariants[1] and statConversionVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, score = scoreAllocPassives, - variants = { - { name = "Energy From Within", rawText = mustGetUniqueRawText("Energy From Within") }, - { name = "Healthy Mind", rawText = mustGetUniqueRawText("Healthy Mind") }, - { name = "Energised Armour", rawText = mustGetUniqueRawText("Energised Armour") }, - }, + variants = statConversionVariants, }) t_insert(jewelTypes, { name = "Attribute Conversion", - radiusIndex = radiusIndexByLabel["Large"], + radiusIndex = attributeConversionVariants[1] and attributeConversionVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, score = scoreAllocPassives, - variants = { - { name = "Brute Force Solution", rawText = mustGetUniqueRawText("Brute Force Solution") }, - { name = "Careful Planning", rawText = mustGetUniqueRawText("Careful Planning") }, - { name = "Efficient Training", rawText = mustGetUniqueRawText("Efficient Training") }, - { name = "Fertile Mind", rawText = mustGetUniqueRawText("Fertile Mind") }, - { name = "Fluid Motion", rawText = mustGetUniqueRawText("Fluid Motion") }, - { name = "Inertia", rawText = mustGetUniqueRawText("Inertia") }, - }, + variants = attributeConversionVariants, }) t_insert(jewelTypes, { name = "Combat Focus", - radiusIndex = radiusIndexByLabel["Medium"], + radiusIndex = combatFocusVariants[1] and combatFocusVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, score = scoreAllocPassives, - variants = { - { name = "Combat Focus (Crimson)", rawText = mustGetUniqueRawText("Combat Focus", "Crimson Jewel") }, - { name = "Combat Focus (Cobalt)", rawText = mustGetUniqueRawText("Combat Focus", "Cobalt Jewel") }, - { name = "Combat Focus (Viridian)", rawText = mustGetUniqueRawText("Combat Focus", "Viridian Jewel") }, - }, + variants = combatFocusVariants, }) t_insert(jewelTypes, { name = "Dreams & Nightmares", - radiusIndex = radiusIndexByLabel["Large"], + radiusIndex = dreamsVariants[1] and dreamsVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, score = scoreAllocPassives, diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index a005a7acd3..66b9386e4a 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -607,8 +607,8 @@ function RadiusJewelFinderClass:buildVariantsFromUniqueItem(uniqueName, baseName return RadiusJewelData.buildVariantsFromUniqueItem(uniqueName, baseName) end -function RadiusJewelFinderClass:discoverFoulbornVariants(uniqueName, radiusIndexByLabel) - return RadiusJewelData.discoverFoulbornVariants(uniqueName, radiusIndexByLabel) +function RadiusJewelFinderClass:discoverFoulbornVariants(uniqueName) + return RadiusJewelData.discoverFoulbornVariants(uniqueName) end -- ───────────────────────────────────────────────────────────────────────────── @@ -1512,7 +1512,7 @@ end -- ── Helper: rebuild jewel type dropdown after filter change ────────────── local function rebuildJewelTypeDropdown() - jewelTypes = buildJewelTypes(radiusIndexByLabel) + jewelTypes = buildJewelTypes() activeJewelTypes = { } jtLabels = { } for _, jt in ipairs(jewelTypes) do From e167e38166622c21c98e989895c14af42d8a158a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 24 May 2026 22:56:38 +0200 Subject: [PATCH 05/52] Derive radius jewel family filter options --- spec/System/TestRadiusJewelFinder_spec.lua | 31 +++++- src/Classes/RadiusJewelFinder.lua | 112 +++++++++++++-------- 2 files changed, 98 insertions(+), 45 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 82e9b30a52..804ea55a5c 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -54,7 +54,7 @@ end -- ───────────────────────────────────────────────────────────────────────────── local function makeFinder() - return new("RadiusJewelFinder", { build = build }) + return new("RadiusJewelFinder"):RadiusJewelFinder({ build = build }) end local function getLargeRadiusIndex() @@ -74,7 +74,7 @@ local function getSmallRadiusIndex() end local function getRadiusIndexFromRawText(rawText) - local item = new("Item", "Rarity: Unique\n" .. rawText) + local item = new("Item"):Item("Rarity: Unique\n" .. rawText) return item.jewelRadiusIndex end @@ -243,7 +243,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end local function tooltipTexts(control, index) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() control.tooltipFunc(tooltip, "DROP", index, control.list[index]) local texts = {} for _, line in ipairs(tooltip.lines) do @@ -254,7 +254,7 @@ describe("RadiusJewelFinder #radius-jewel", function() return texts end local function buttonTooltipTexts(control, ...) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() control.tooltipFunc(tooltip, ...) local texts = {} for _, line in ipairs(tooltip.lines) do @@ -463,6 +463,29 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.are.equal(1, popup.controls.jewelVariantSelect.selIndex) assert.is_false(popup.controls.findButton:IsShown(), "Find should be hidden while all variants are selected") + assert.is_true(popup.controls.variantFamilySelect.shown, + "expected Dreams & Nightmares to show the family filter") + assert.is_true(popup.controls.variantFamilySelect.x < popup.controls.jewelVariantSelect.x, + "expected Family to filter Variant from left to right") + assert.are.same({ + "All", + "Red Dream", + "Red Nightmare", + "Green Dream", + "Green Nightmare", + "Blue Dream", + "Blue Nightmare", + }, listLabels(popup.controls.variantFamilySelect.list)) + local redNightmareFamilyIdx = findIndex(popup.controls.variantFamilySelect.list, "Red Nightmare") + assert.is_not_nil(redNightmareFamilyIdx, "expected Red Nightmare in family filter") + popup.controls.variantFamilySelect.selFunc(redNightmareFamilyIdx) + local redNightmareFamilyLabels = listLabels(popup.controls.jewelVariantSelect.list) + assert.are.equal("All variants", redNightmareFamilyLabels[1]) + for i = 2, #redNightmareFamilyLabels do + assert.is_true(redNightmareFamilyLabels[i]:find("Red Nightmare", 1, true) ~= nil, + "family filter should only show Red Nightmare variants: " .. redNightmareFamilyLabels[i]) + end + popup.controls.variantFamilySelect.selFunc(1) 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) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 66b9386e4a..216f54d1d5 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -995,6 +995,7 @@ function RadiusJewelFinderClass:Open() local LARGE_IDX = radiusIndexByLabel["Large"] local jewelTypes local jewelSockets = self:buildJewelSockets(LARGE_IDX) + local ALL_VARIANT_FAMILIES_VALUE = "ALL" -- Mutable state local showLegacy = false @@ -1005,16 +1006,8 @@ function RadiusJewelFinderClass:Open() local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1] local selectedMaxPoints = 20 local selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[1] - local dreamFamilyOptions = { - { name = "All", value = "ALL" }, - { name = "Red Dream", value = "The Red Dream" }, - { name = "Red Nightmare", value = "The Red Nightmare" }, - { name = "Green Dream", value = "The Green Dream" }, - { name = "Green Nightmare", value = "The Green Nightmare" }, - { name = "Blue Dream", value = "The Blue Dream" }, - { name = "Blue Nightmare", value = "The Blue Nightmare" }, - } - local selectedDreamFamily = dreamFamilyOptions[1] + local variantFamilyOptions = { { name = "All", value = ALL_VARIANT_FAMILIES_VALUE } } + local selectedDreamFamily = variantFamilyOptions[1] local TL = { "TOPLEFT", nil, "TOPLEFT" } local BL = { "BOTTOMLEFT", nil, "BOTTOMLEFT" } @@ -1026,6 +1019,12 @@ function RadiusJewelFinderClass:Open() local popupWidth = edgePadding * 3 + leftPanelWidth + rightPanelWidth local popupHeight = 474 local rightPanelX = edgePadding * 2 + leftPanelWidth + local variantDefaultX = 278 + local variantDefaultWidth = 260 + local variantFamilyX = variantDefaultX + local variantFamilyWidth = 150 + local variantFilteredX = variantFamilyX + variantFamilyWidth + 8 + local variantFilteredWidth = edgePadding + leftPanelWidth - variantFilteredX local bottomButtonY = -edgePadding local bottomInputY = -(edgePadding + 2) local bottomLabelY = -(edgePadding + 4) @@ -1198,19 +1197,51 @@ function RadiusJewelFinderClass:Open() local methods = getSelectedComputeMethods() return methods and #methods > 0 end - local function hasVariantFamilies() - if not selectedJewelType or not selectedJewelType.variants then return false end - for _, v in ipairs(selectedJewelType.variants) do - if v.family then return true end + + local function makeVariantFamilyLabel(family) + return family:gsub("^The%s+", "") + end + + local function buildVariantFamilyOptions(variants) + local options = { { name = "All", value = ALL_VARIANT_FAMILIES_VALUE } } + local seen = { } + for _, variant in ipairs(variants or { }) do + local family = variant.family + if family and not seen[family] then + seen[family] = true + t_insert(options, { name = makeVariantFamilyLabel(family), value = family }) + end + end + return options + end + + local function syncVariantFamilySelect() + variantFamilyOptions = buildVariantFamilyOptions(selectedJewelType and selectedJewelType.variants) + local labels = { } + local selectedIndex = 1 + for i, option in ipairs(variantFamilyOptions) do + t_insert(labels, option.name) + if selectedDreamFamily and option.value == selectedDreamFamily.value then + selectedIndex = i + end + end + selectedDreamFamily = variantFamilyOptions[selectedIndex] + if controls.variantFamilySelect then + controls.variantFamilySelect:SetList(labels) + controls.variantFamilySelect.selIndex = selectedIndex end - return false + return #variantFamilyOptions > 1 + end + + local function hasVariantFamilies() + return #variantFamilyOptions > 1 end local function getDisplayedVariants() if not selectedJewelType or not selectedJewelType.variants then return nil end - if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= "ALL" then + if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= ALL_VARIANT_FAMILIES_VALUE then local variants = { } for _, variant in ipairs(selectedJewelType.variants) do if variant.family == selectedDreamFamily.value then @@ -1673,8 +1704,8 @@ end controls.allJewelsViewSelect.shown = false -- Thread ring selector (shown when Thread of Hope selected) - controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { 278, 10, 0, 16 }, "^7Preview ring:") - controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { 278, 26, 200, 20 }, tvLabels, function(idx) + controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, 10, 0, 16 }, "^7Preview ring:") + controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, 26, 200, 20 }, tvLabels, function(idx) cancelCompute() selectedThreadVariant = threadVariants[idx] saveFinderState() @@ -1684,18 +1715,10 @@ end controls.threadVariantLabel.shown = false controls.threadVariantSelect.shown = false - controls.variantFamilyLabel = new("LabelControl"):LabelControl(TL, { 550, 10, 0, 16 }, "^7Family:") - controls.variantFamilySelect = new("DropDownControl"):DropDownControl(TL, { 550, 26, 220, 20 }, { - "All", - "Red Dream", - "Red Nightmare", - "Green Dream", - "Green Nightmare", - "Blue Dream", - "Blue Nightmare", - }, function(idx) + controls.variantFamilyLabel = new("LabelControl"):LabelControl(TL, { variantFamilyX, 10, 0, 16 }, "^7Family:") + controls.variantFamilySelect = new("DropDownControl"):DropDownControl(TL, { variantFamilyX, 26, variantFamilyWidth, 20 }, { "All" }, function(idx) cancelCompute() - selectedDreamFamily = dreamFamilyOptions[idx] + selectedDreamFamily = variantFamilyOptions[idx] or variantFamilyOptions[1] controls.jewelVariantSelect.selIndex = 1 selectedJewelVariant = nil syncDisplayedVariants() @@ -1707,8 +1730,8 @@ end controls.variantFamilySelect.shown = false -- Jewel variant selector (shown when jewel type has built-in variants) - controls.jewelVariantLabel = new("LabelControl"):LabelControl(TL, { 278, 10, 0, 16 }, "^7Variant:") - controls.jewelVariantSelect = new("DropDownControl"):DropDownControl(TL, { 278, 26, 260, 20 }, {}, function(idx) + controls.jewelVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, 10, 0, 16 }, "^7Variant:") + controls.jewelVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, 26, variantDefaultWidth, 20 }, {}, function(idx) cancelCompute() local variants = getDisplayedVariants() if variants then @@ -1726,6 +1749,18 @@ end controls.jewelVariantLabel.shown = false controls.jewelVariantSelect.shown = false + local function syncVariantControlLayout(hasVariantFamilyFilter) + if hasVariantFamilyFilter 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 @@ -1777,8 +1812,9 @@ end controls.allJewelsViewSelect.shown = false local isThread = selectedJewelType.isThread == true local hasVariants = selectedJewelType.variants ~= nil - local hasVariantFamilyFilter = hasVariantFamilies() + local hasVariantFamilyFilter = syncVariantFamilySelect() local hasComputeMethods = selectedJewelSupportsComputeMethods() + syncVariantControlLayout(hasVariantFamilyFilter) controls.threadVariantLabel.shown = isThread controls.threadVariantSelect.shown = isThread @@ -1799,7 +1835,7 @@ end if hasVariants then if not hasVariantFamilyFilter then - selectedDreamFamily = dreamFamilyOptions[1] + selectedDreamFamily = variantFamilyOptions[1] controls.variantFamilySelect.selIndex = 1 end syncDisplayedVariants() @@ -2157,7 +2193,7 @@ end socketResults, baseline = self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) elseif displayedVariants and #displayedVariants > 0 then - if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= "ALL" then + if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= ALL_VARIANT_FAMILIES_VALUE then itemLabel = selectedDreamFamily.name end socketResults, baseline = @@ -2571,13 +2607,7 @@ end end if finderState.dreamFamilyValue then - for i, option in ipairs(dreamFamilyOptions) do - if option.value == finderState.dreamFamilyValue then - selectedDreamFamily = option - controls.variantFamilySelect.selIndex = i - break - end - end + selectedDreamFamily = { value = finderState.dreamFamilyValue } end syncSelectedJewelTypeControls() From 503aed74f2d0e5a8aeab16d6312a20b18c6c2b89 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 24 May 2026 23:37:49 +0200 Subject: [PATCH 06/52] Clarify radius jewel variant filtering --- spec/System/TestRadiusJewelFinder_spec.lua | 37 +++-- src/Classes/RadiusJewelData.lua | 20 +-- src/Classes/RadiusJewelFinder.lua | 149 +++++++++++---------- 3 files changed, 105 insertions(+), 101 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 804ea55a5c..b680954cf5 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -463,29 +463,22 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.are.equal(1, popup.controls.jewelVariantSelect.selIndex) assert.is_false(popup.controls.findButton:IsShown(), "Find should be hidden while all variants are selected") - assert.is_true(popup.controls.variantFamilySelect.shown, - "expected Dreams & Nightmares to show the family filter") - assert.is_true(popup.controls.variantFamilySelect.x < popup.controls.jewelVariantSelect.x, - "expected Family to filter Variant from left to right") - assert.are.same({ - "All", - "Red Dream", - "Red Nightmare", - "Green Dream", - "Green Nightmare", - "Blue Dream", - "Blue Nightmare", - }, listLabels(popup.controls.variantFamilySelect.list)) - local redNightmareFamilyIdx = findIndex(popup.controls.variantFamilySelect.list, "Red Nightmare") - assert.is_not_nil(redNightmareFamilyIdx, "expected Red Nightmare in family filter") - popup.controls.variantFamilySelect.selFunc(redNightmareFamilyIdx) - local redNightmareFamilyLabels = listLabels(popup.controls.jewelVariantSelect.list) - assert.are.equal("All variants", redNightmareFamilyLabels[1]) - for i = 2, #redNightmareFamilyLabels do - assert.is_true(redNightmareFamilyLabels[i]:find("Red Nightmare", 1, true) ~= nil, - "family filter should only show Red Nightmare variants: " .. redNightmareFamilyLabels[i]) + 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 - popup.controls.variantFamilySelect.selFunc(1) 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) diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index af126af8e2..416987f6b5 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -121,7 +121,7 @@ local function getRadiusIndexFromRawText(rawText) if not rawText then return nil end - local item = new("Item", "Rarity: Unique\n" .. rawText) + local item = new("Item"):Item("Rarity: Unique\n" .. rawText) return item.jewelRadiusIndex end @@ -593,7 +593,7 @@ local function previewThreadOfHope(ringName) local rawText = mustGetUniqueRawText("Thread of Hope") local displayName if ringName then - local item = new("Item", "Rarity: Unique\n" .. rawText) + 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 @@ -777,7 +777,7 @@ function M.buildJewelTypes() appendFoulbornVariants(intuitiveLeap, foulbornVariants) end - local dreamsNightmaresFamilies = { + local dreamsNightmaresJewels = { { name = "The Red Dream" }, { name = "The Red Nightmare" }, { name = "The Green Dream" }, @@ -786,18 +786,18 @@ function M.buildJewelTypes() { name = "The Blue Nightmare" }, } local dreamsVariants = { } - for _, familyInfo in ipairs(dreamsNightmaresFamilies) do - local rawText = mustGetCurrentUniqueRawText(familyInfo.name) + for _, jewelInfo in ipairs(dreamsNightmaresJewels) do + local rawText = mustGetCurrentUniqueRawText(jewelInfo.name) t_insert(dreamsVariants, { - name = familyInfo.name, - family = familyInfo.name, + name = jewelInfo.name, + variantGroup = jewelInfo.name, rawText = rawText, radiusIndex = getRadiusIndexFromRawText(rawText), }) - local foulbornVariants = discoverFoulbornVariants(familyInfo.name) + local foulbornVariants = discoverFoulbornVariants(jewelInfo.name) for _, variant in ipairs(foulbornVariants) do - variant.family = familyInfo.name - variant.name = familyInfo.name .. " (" .. variant.name .. ")" + variant.variantGroup = jewelInfo.name + variant.name = jewelInfo.name .. " (" .. variant.name .. ")" t_insert(dreamsVariants, variant) end end diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 216f54d1d5..c21cdf0f89 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -995,7 +995,7 @@ function RadiusJewelFinderClass:Open() local LARGE_IDX = radiusIndexByLabel["Large"] local jewelTypes local jewelSockets = self:buildJewelSockets(LARGE_IDX) - local ALL_VARIANT_FAMILIES_VALUE = "ALL" + local ALL_VARIANT_GROUPS_VALUE = "ALL" -- Mutable state local showLegacy = false @@ -1006,8 +1006,8 @@ function RadiusJewelFinderClass:Open() local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1] local selectedMaxPoints = 20 local selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[1] - local variantFamilyOptions = { { name = "All", value = ALL_VARIANT_FAMILIES_VALUE } } - local selectedDreamFamily = variantFamilyOptions[1] + local variantGroupOptions = { { name = "All", value = ALL_VARIANT_GROUPS_VALUE } } + local selectedVariantGroup = variantGroupOptions[1] local TL = { "TOPLEFT", nil, "TOPLEFT" } local BL = { "BOTTOMLEFT", nil, "BOTTOMLEFT" } @@ -1019,11 +1019,16 @@ function RadiusJewelFinderClass:Open() local popupWidth = edgePadding * 3 + leftPanelWidth + rightPanelWidth local popupHeight = 474 local rightPanelX = edgePadding * 2 + leftPanelWidth + local headerLabelY = 18 + local headerInputY = 34 + local statusLabelY = 62 + local contentTopY = 78 + local resultListBottomY = 430 local variantDefaultX = 278 local variantDefaultWidth = 260 - local variantFamilyX = variantDefaultX - local variantFamilyWidth = 150 - local variantFilteredX = variantFamilyX + variantFamilyWidth + 8 + local variantGroupX = variantDefaultX + local variantGroupWidth = 150 + local variantFilteredX = variantGroupX + variantGroupWidth + 8 local variantFilteredWidth = edgePadding + leftPanelWidth - variantFilteredX local bottomButtonY = -edgePadding local bottomInputY = -(edgePadding + 2) @@ -1088,7 +1093,8 @@ function RadiusJewelFinderClass:Open() finderState.jewelTypeName = selectedJewelType and selectedJewelType.name or nil finderState.jewelVariantName = selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or nil finderState.threadVariantName = selectedThreadVariant and selectedThreadVariant.name or nil - finderState.dreamFamilyValue = selectedDreamFamily and selectedDreamFamily.value or nil + finderState.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 @@ -1104,7 +1110,7 @@ function RadiusJewelFinderClass:Open() selectedJewelType and selectedJewelType.name or "", selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or "", selectedThreadVariant and selectedThreadVariant.name or "", - selectedDreamFamily and selectedDreamFamily.value or "", + selectedVariantGroup and selectedVariantGroup.value or "", selectedImpactStat and selectedImpactStat.field or "", computeMethodKey, selectedMaxPoints and tostring(selectedMaxPoints) or "", @@ -1198,53 +1204,58 @@ function RadiusJewelFinderClass:Open() return methods and #methods > 0 end - local function makeVariantFamilyLabel(family) - return family:gsub("^The%s+", "") + local function makeVariantGroupLabel(group) + return group:gsub("^The%s+", "") end - local function buildVariantFamilyOptions(variants) - local options = { { name = "All", value = ALL_VARIANT_FAMILIES_VALUE } } - local seen = { } + local function buildVariantGroupOptions(variants) + local options = { { name = "All", value = ALL_VARIANT_GROUPS_VALUE } } + local counts = { } for _, variant in ipairs(variants or { }) do - local family = variant.family - if family and not seen[family] then - seen[family] = true - t_insert(options, { name = makeVariantFamilyLabel(family), value = family }) + 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 syncVariantFamilySelect() - variantFamilyOptions = buildVariantFamilyOptions(selectedJewelType and selectedJewelType.variants) + local function syncVariantGroupSelect() + variantGroupOptions = buildVariantGroupOptions(selectedJewelType and selectedJewelType.variants) local labels = { } local selectedIndex = 1 - for i, option in ipairs(variantFamilyOptions) do + for i, option in ipairs(variantGroupOptions) do t_insert(labels, option.name) - if selectedDreamFamily and option.value == selectedDreamFamily.value then + if selectedVariantGroup and option.value == selectedVariantGroup.value then selectedIndex = i end end - selectedDreamFamily = variantFamilyOptions[selectedIndex] - if controls.variantFamilySelect then - controls.variantFamilySelect:SetList(labels) - controls.variantFamilySelect.selIndex = selectedIndex + selectedVariantGroup = variantGroupOptions[selectedIndex] + if controls.variantGroupSelect then + controls.variantGroupSelect:SetList(labels) + controls.variantGroupSelect.selIndex = selectedIndex end - return #variantFamilyOptions > 1 + return #variantGroupOptions > 1 end - local function hasVariantFamilies() - return #variantFamilyOptions > 1 + local function hasVariantGroups() + return #variantGroupOptions > 1 end local function getDisplayedVariants() if not selectedJewelType or not selectedJewelType.variants then return nil end - if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= ALL_VARIANT_FAMILIES_VALUE then + if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then local variants = { } for _, variant in ipairs(selectedJewelType.variants) do - if variant.family == selectedDreamFamily.value then + if variant.variantGroup == selectedVariantGroup.value then t_insert(variants, variant) end end @@ -1340,7 +1351,7 @@ end return (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) or (controls.jewelVariantSelect and controls.jewelVariantSelect.dropped) or (controls.threadVariantSelect and controls.threadVariantSelect.dropped) - or (controls.variantFamilySelect and controls.variantFamilySelect.dropped) + or (controls.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) @@ -1395,10 +1406,10 @@ end -- ── Preview list (right panel) ──────────────────────────────────────────── local previewListData = { } local resultDetailListData = { } - local previewListY = 70 + local previewListY = contentTopY local previewListHeight = 180 local compactPreviewListHeight = 48 - local resultDetailBottomY = 430 + local resultDetailBottomY = resultListBottomY local resultDetailGap = 6 local resultDetailLabelGap = 18 local function getSelectedAllJewelPreviewLines() @@ -1528,7 +1539,7 @@ end end -- ── Results list (left panel) ───────────────────────────────────────────── - controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, 70, leftPanelWidth, 360 }, self.build, socketViewer) + controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, contentTopY, leftPanelWidth, resultListBottomY - contentTopY }, self.build, socketViewer) controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped controls.resultsList.OnSelect = function(_, _, row) updateResultDetails(row) @@ -1595,10 +1606,10 @@ end rebuildJewelTypeDropdown() -- initial build (controls.jewelTypeSelect not yet created) -- ── Header controls ─────────────────────────────────────────────────────── - controls.jewelTypeLabel = new("LabelControl"):LabelControl(TL, { edgePadding, 10, 0, 16 }, "^7Type:") + controls.jewelTypeLabel = new("LabelControl"):LabelControl(TL, { edgePadding, headerLabelY, 0, 16 }, "^7Type:") - controls.computeMethodLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, 10, 0, 16 }, "^7Method:") - controls.computeMethodSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX, 26, 160, buttonHeight }, { }, function(idx) + controls.computeMethodLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, headerLabelY, 0, 16 }, "^7Method:") + controls.computeMethodSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX, headerInputY, 160, buttonHeight }, { }, function(idx) cancelCompute() local methods = getSelectedComputeMethods() if methods then @@ -1627,8 +1638,8 @@ end controls.computeMethodSelect.shown = false -- Impact stat selector (shown when jewel has compute) - controls.impactStatLabel = new("LabelControl"):LabelControl(TL, { rightPanelX + 180, 10, 0, 16 }, "^7Stat:") - controls.impactStatSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX + 180, 26, 140, buttonHeight }, impactStatLabels, function(idx) + 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) cancelCompute() selectedImpactStat = IMPACT_STATS[idx] saveFinderState() @@ -1678,8 +1689,8 @@ end controls.occupiedModeSelect.shown = true -- All-jewels view mode selector - controls.allJewelsViewLabel = new("LabelControl"):LabelControl(TL, { 278, 10, 0, 16 }, "^7View:") - controls.allJewelsViewSelect = new("DropDownControl"):DropDownControl(TL, { 278, 26, 160, 20 }, allJewelsViewLabels, function(idx) + 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 lastComputeAllRows then local displayRows = selectedAllJewelsView.id == "bestPerSocket" @@ -1704,8 +1715,8 @@ end controls.allJewelsViewSelect.shown = false -- Thread ring selector (shown when Thread of Hope selected) - controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, 10, 0, 16 }, "^7Preview ring:") - controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, 26, 200, 20 }, tvLabels, function(idx) + controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Preview ring:") + controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx) cancelCompute() selectedThreadVariant = threadVariants[idx] saveFinderState() @@ -1715,10 +1726,10 @@ end controls.threadVariantLabel.shown = false controls.threadVariantSelect.shown = false - controls.variantFamilyLabel = new("LabelControl"):LabelControl(TL, { variantFamilyX, 10, 0, 16 }, "^7Family:") - controls.variantFamilySelect = new("DropDownControl"):DropDownControl(TL, { variantFamilyX, 26, variantFamilyWidth, 20 }, { "All" }, function(idx) + controls.variantGroupLabel = new("LabelControl"):LabelControl(TL, { variantGroupX, headerLabelY, 0, 16 }, "^7Jewel:") + controls.variantGroupSelect = new("DropDownControl"):DropDownControl(TL, { variantGroupX, headerInputY, variantGroupWidth, 20 }, { "All" }, function(idx) cancelCompute() - selectedDreamFamily = variantFamilyOptions[idx] or variantFamilyOptions[1] + selectedVariantGroup = variantGroupOptions[idx] or variantGroupOptions[1] controls.jewelVariantSelect.selIndex = 1 selectedJewelVariant = nil syncDisplayedVariants() @@ -1726,12 +1737,12 @@ end updatePreview() runFind(false) end) - controls.variantFamilyLabel.shown = false - controls.variantFamilySelect.shown = false + 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, 10, 0, 16 }, "^7Variant:") - controls.jewelVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, 26, variantDefaultWidth, 20 }, {}, function(idx) + controls.jewelVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Variant:") + controls.jewelVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, variantDefaultWidth, 20 }, {}, function(idx) cancelCompute() local variants = getDisplayedVariants() if variants then @@ -1749,8 +1760,8 @@ end controls.jewelVariantLabel.shown = false controls.jewelVariantSelect.shown = false - local function syncVariantControlLayout(hasVariantFamilyFilter) - if hasVariantFamilyFilter then + local function syncVariantControlLayout(hasVariantGroupFilter) + if hasVariantGroupFilter then controls.jewelVariantLabel.x = variantFilteredX controls.jewelVariantSelect.x = variantFilteredX controls.jewelVariantSelect.width = variantFilteredWidth @@ -1790,8 +1801,8 @@ end controls.allJewelsViewSelect.shown = true controls.threadVariantLabel.shown = false controls.threadVariantSelect.shown = false - controls.variantFamilyLabel.shown = false - controls.variantFamilySelect.shown = false + controls.variantGroupLabel.shown = false + controls.variantGroupSelect.shown = false controls.jewelVariantLabel.shown = false controls.jewelVariantSelect.shown = false controls.computeMethodLabel.shown = true @@ -1812,14 +1823,14 @@ end controls.allJewelsViewSelect.shown = false local isThread = selectedJewelType.isThread == true local hasVariants = selectedJewelType.variants ~= nil - local hasVariantFamilyFilter = syncVariantFamilySelect() + local hasVariantGroupFilter = syncVariantGroupSelect() local hasComputeMethods = selectedJewelSupportsComputeMethods() - syncVariantControlLayout(hasVariantFamilyFilter) + syncVariantControlLayout(hasVariantGroupFilter) controls.threadVariantLabel.shown = isThread controls.threadVariantSelect.shown = isThread - controls.variantFamilyLabel.shown = hasVariantFamilyFilter - controls.variantFamilySelect.shown = hasVariantFamilyFilter + controls.variantGroupLabel.shown = hasVariantGroupFilter + controls.variantGroupSelect.shown = hasVariantGroupFilter controls.jewelVariantLabel.shown = hasVariants controls.jewelVariantSelect.shown = hasVariants controls.computeMethodLabel.shown = hasComputeMethods @@ -1834,9 +1845,9 @@ end end if hasVariants then - if not hasVariantFamilyFilter then - selectedDreamFamily = variantFamilyOptions[1] - controls.variantFamilySelect.selIndex = 1 + if not hasVariantGroupFilter then + selectedVariantGroup = variantGroupOptions[1] + controls.variantGroupSelect.selIndex = 1 end syncDisplayedVariants() else @@ -1851,7 +1862,7 @@ end end -- Jewel type dropdown (defined after variant controls so :Click() is safe) - controls.jewelTypeSelect = new("DropDownControl"):DropDownControl(TL, { 10, 26, 260, 20 }, jtLabels, function(idx) + controls.jewelTypeSelect = new("DropDownControl"):DropDownControl(TL, { 10, headerInputY, 260, 20 }, jtLabels, function(idx) cancelCompute() selectedJewelType = activeJewelTypes[idx] controls.jewelVariantSelect.selIndex = 1 @@ -2062,7 +2073,7 @@ end return rows end - controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, 26, 72, buttonHeight }, "Compute", function() + controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, headerInputY, 72, buttonHeight }, "Compute", function() if computeContext then cancelCompute("^8Compute stopped") restoreCachedResults() @@ -2193,8 +2204,8 @@ end socketResults, baseline = self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) elseif displayedVariants and #displayedVariants > 0 then - if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= ALL_VARIANT_FAMILIES_VALUE then - itemLabel = selectedDreamFamily.name + if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then + itemLabel = selectedVariantGroup.name end socketResults, baseline = self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) @@ -2252,12 +2263,12 @@ end controls.computeButton.shown = true -- Status label - controls.statusLabel = new("LabelControl"):LabelControl(TL, { 10, 54, 400, 16 }, COL_META .. "Click Find to search") + 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", { }, COL_META .. "Click Compute to rank all jewels") end - controls.showLegacyCheck = new("CheckBoxControl"):CheckBoxControl(TL, { 700, 54, 18 }, "Show legacy", function(state) + controls.showLegacyCheck = new("CheckBoxControl"):CheckBoxControl(TL, { 700, statusLabelY, 18 }, "Show legacy", function(state) cancelCompute() showLegacy = state saveFinderState() @@ -2606,8 +2617,8 @@ end selectedJewelType = activeJewelTypes[jewelTypeIndex] end - if finderState.dreamFamilyValue then - selectedDreamFamily = { value = finderState.dreamFamilyValue } + if finderState.variantGroupValue or finderState.dreamFamilyValue then + selectedVariantGroup = { value = finderState.variantGroupValue or finderState.dreamFamilyValue } end syncSelectedJewelTypeControls() From ab2fbd39699a4d4e34a57ff84ca60bcc05f40ab9 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 21:07:41 +0200 Subject: [PATCH 07/52] Support Foulborn variants in Radius Jewel Finder Adapt the finder to the current per-mod Foulborn data model, preserve selected variants through Find and Compute, and cover the regression paths. Excludes Foulborn Might of the Meek until its radius is modelled. --- spec/System/TestRadiusJewelFinder_spec.lua | 320 +++++++++++++++++++-- src/Classes/RadiusJewelCompute.lua | 32 +++ src/Classes/RadiusJewelData.lua | 219 ++++++++------ src/Classes/RadiusJewelFinder.lua | 17 +- 4 files changed, 467 insertions(+), 121 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index b680954cf5..8b96cb175a 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -445,7 +445,21 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_true(#typeTooltipTexts > 0, "expected jewel type tooltip content") assert.is_true(typeTooltipTexts[1]:find("Intuitive Leap", 1, true) ~= nil, "expected type tooltip to describe Intuitive Leap") - assert.is_true(popup.controls.findButton:IsShown(), "Find should be shown for a single jewel type") + 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_false(popup.controls.findButton:IsShown(), + "Find should stay hidden while all Intuitive Leap variants are selected") + 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, @@ -490,6 +504,26 @@ describe("RadiusJewelFinder #radius-jewel", function() 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") @@ -671,38 +705,270 @@ describe("RadiusJewelFinder #radius-jewel", function() 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", "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) - -- ── discoverFoulbornVariants ───────────────────────────────────────────── + -- ── Foulborn radius-jewel variants ─────────────────────────────────────── - describe("discoverFoulbornVariants", function() + describe("buildFoulbornVariants", function() - it("returns empty table when no Foulborn data exists", function() - local variants = makeFinder():discoverFoulbornVariants("Might of the Meek") - assert.is_table(variants) - -- Some data sets include Foulborn items and some do not. - local hasFoulborn = false - if data.uniques.generated then - for _, rawText in ipairs(data.uniques.generated) do - if type(rawText) == "string" and rawText:match("^Foulborn ") then - hasFoulborn = true - break - end + 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 = makeFinder():buildFoulbornVariants("Unnatural Instinct", nil, { + ["Unnatural Instinct"] = { [originalModId] = newModId }, + }) + assert.are.equal(1, #variants) + assert.are.same({ newModId }, variants[1].newModIds) + + local imported = new("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, #makeFinder():buildFoulbornVariants("Anatomical Knowledge")) + end) + + it("builds every non-empty Unnatural Instinct mutation subset", function() + local variants = makeFinder():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", "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 - if not hasFoulborn then - assert.are.equal(0, #variants, "expected no Foulborn variants when no Foulborn data exists") - else - assert.is_true(#variants > 0, "expected Foulborn variants when Foulborn data exists") - for _, v in ipairs(variants) do - assert.is_string(v.name) - assert.is_string(v.rawText) - assert.is_true(v.isFoulborn) - assert.is_number(v.comboIndex) + end) + + 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(makeFinder():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 = makeFinder():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, #makeFinder():buildFoulbornVariants("Might of the Meek")) + end) + + it("marks Foulborn Intuitive Leap as Massive Radius keystone-only in preview and compute", function() + local variants = makeFinder():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 preview = RadiusJewelData.jewelPreviewFn["Intuitive Leap"](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.collectDisconnectedPassiveCandidates + function finder:collectDisconnectedPassiveCandidates(socketNode, options) + capturedOptions = options + return { } + end + local sockets = finder:buildJewelSockets(getSmallRadiusIndex()) + finder:computeIntuitiveLeapSocketImpact({ sockets[1] }, "Life", variant, "fast", { }, nil, nil, { id = "all" }) + finder.collectDisconnectedPassiveCandidates = originalCollect + + assert.is_not_nil(capturedOptions) + assert.is_true(capturedOptions.keystoneOnly) + assert.is_function(capturedOptions.collectNodes) + + local massiveRadiusIndex + for index, radius in ipairs(data.jewelRadius) do + if radius.outer > data.jewelRadius[getSmallRadiusIndex()].outer and radius.outer <= 2400 then + massiveRadiusIndex = index + break + end + end + assert.is_not_nil(massiveRadiusIndex, "expected a radius beyond Small and within Massive Radius") + local massiveKeystone = { id = "foulbornMassiveKeystone", type = "Keystone" } + local syntheticSocket = { + nodesInRadius = { + [getSmallRadiusIndex()] = { normalPassive = { id = "normalPassive", type = "Normal" } }, + [massiveRadiusIndex] = { foulbornMassiveKeystone = massiveKeystone }, + }, + } + local candidates = finder:collectDisconnectedPassiveCandidates(syntheticSocket, capturedOptions) + assert.are.same({ massiveKeystone }, candidates) + 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:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant) + computedVariants[#computedVariants + 1] = variant + return { + { + socket = sockets[1], + delta = variant.isFoulborn and 2 or 1, + addedNodeCount = 0, + }, + }, 100 + end + local results, baseline = finder:computeBestIntuitiveLeapSocketImpact({ { id = "testSocket" } }, "Life", intuitiveVariants, "fast", { }) + 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) -- ── computeBestVariantSocketImpact (The Light of Meaning) ──────────────── @@ -1432,6 +1698,14 @@ describe("RadiusJewelFinder #radius-jewel", function() 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("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" }) diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 650c6e0740..2ad7a176f3 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -601,6 +601,7 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, socket.label, socketProgress, maxAdditionalNodes) end 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) @@ -613,6 +614,37 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me return results, realBaseline end +function Class:computeBestIntuitiveLeapSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + if not variants or #variants == 0 then + return self:computeIntuitiveLeapSocketImpact(sockets, impactStat, nil, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + end + local bestBySocket = { } + local realBaseline + local variantCount = #variants + for variantIndex, variant in ipairs(variants) do + local variantProgress = progressChild(progress, (variantIndex - 1) / variantCount, 1 / variantCount) + local results, baseline = self:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, + variantProgress, maxTotalPoints, occupiedMode, skipPlanSteps) + 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 Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVariants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 416987f6b5..10f40a03b9 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -166,29 +166,6 @@ end M.buildVariantsFromUniqueItem = buildVariantsFromUniqueItem -local function discoverFoulbornVariants(uniqueName) - local variants = { } - local generated = data.uniques.generated - if not generated then return variants end - local escapedName = uniqueName:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") - for _, rawText in ipairs(generated) do - local comboIndex = rawText:match("^Foulborn " .. escapedName .. " (%d+)\n") - if comboIndex then - t_insert(variants, { - name = "Foulborn " .. comboIndex, - rawText = rawText, - radiusIndex = getRadiusIndexFromRawText(rawText), - isFoulborn = true, - comboIndex = tonumber(comboIndex), - }) - end - end - t_sort(variants, function(a, b) return a.comboIndex < b.comboIndex end) - return variants -end - -M.discoverFoulbornVariants = discoverFoulbornVariants - -- ───────────────────────────────────────────────────────────────────────────── -- Scoring functions -- ───────────────────────────────────────────────────────────────────────────── @@ -283,75 +260,146 @@ local function makeRadiusAttributeDetail(attributeLabel, includeAllocated, inclu end -- ───────────────────────────────────────────────────────────────────────────── --- Foulborn finder fields +-- Foulborn finder variants -- ───────────────────────────────────────────────────────────────────────────── --- Foulborn variants are discovered first, then the finder adds local fields --- such as scoreLabel, score, and keystoneOnly. +-- 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 function addUnnaturalInstinctFoulbornFields(variant) - local typeMap = { Notable = "Notable", Small = "Normal" } - local rawText = variant.rawText - local gainLabel = rawText:match("Unallocated (%w+) Passive Skills") - local loseLabel = rawText:match("Allocated (%w+) Passive Skills.-grant nothing") - local gainType = gainLabel and typeMap[gainLabel] - local loseType = loseLabel and typeMap[loseLabel] - if gainType and loseType then - local gainShort = gainType == "Notable" and "notable" or "small" - local loseShort = loseType == "Notable" and "notable" or "small" - variant.scoreLabel = "unalloc " .. gainShort .. " - alloc " .. loseShort - variant.score = function(nodes, allocNodes) - return scoreGainLoss(nodes, allocNodes, gainType, loseType) +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) - local rawText = variant.rawText - if rawText:match("If no Notables Allocated") then - variant.scoreLabel = "no alloc notables" - variant.score = function(nodes, allocNodes) - for nodeId, node in pairs(nodes) do - if allocNodes[nodeId] and node.type == "Notable" then - return 0 - end + 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 - return 1 end - elseif rawText:match("Small Passives Allocated") then - variant.scoreLabel = "alloc small passives" - variant.score = function(nodes, allocNodes) - local s = 0 - for nodeId, node in pairs(nodes) do - if allocNodes[nodeId] and node.type == "Normal" then - s = s + 1 - end + 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.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 - return s end + return s end end -local function addIntuitiveLeapFoulbornFields(variant) - local rawText = variant.rawText - if rawText:match("Massive Radius") then - variant.isMassiveRadius = true - end - if rawText:match("Keystone Passive Skills") then - variant.keystoneOnly = true - variant.scoreLabel = "unalloc keystones" - variant.score = function(nodes, allocNodes) - local s = 0 - for nodeId, node in pairs(nodes) do - if not allocNodes[nodeId] and node.type == "Keystone" then - s = s + 1 - end +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", "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 - return s 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 -local function appendFoulbornVariants(jewelType, foulbornVariants) +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 }, @@ -574,7 +622,7 @@ end local function previewVariant(variant, displayName) if variant and variant.rawText then - return previewFromRawText(variant.rawText, displayName or variant.name) + return previewFromRawText(variant.rawText, displayName or variant.name, variant.previewMeta) end return nil end @@ -703,7 +751,6 @@ function M.buildJewelTypes() return s end, } - appendFoulbornVariants(mightOfTheMeek, discoverFoulbornVariants("Might of the Meek")) local inspiredLearning = { name = "Inspired Learning", @@ -721,11 +768,7 @@ function M.buildJewelTypes() return s end, } - do - local foulbornVariants = discoverFoulbornVariants("Inspired Learning") - for _, variant in ipairs(foulbornVariants) do addInspiredLearningFoulbornFields(variant) end - appendFoulbornVariants(inspiredLearning, foulbornVariants) - end + appendFoulbornVariants(inspiredLearning, "Inspired Learning") local unnaturalInstinct = { name = "Unnatural Instinct", @@ -744,11 +787,7 @@ function M.buildJewelTypes() return gained - lost end, } - do - local foulbornVariants = discoverFoulbornVariants("Unnatural Instinct") - for _, variant in ipairs(foulbornVariants) do addUnnaturalInstinctFoulbornFields(variant) end - appendFoulbornVariants(unnaturalInstinct, foulbornVariants) - end + appendFoulbornVariants(unnaturalInstinct, "Unnatural Instinct") local lioneyesFall = { name = "Lioneye's Fall", @@ -758,7 +797,7 @@ function M.buildJewelTypes() rawText = mustGetUniqueRawText("Lioneye's Fall"), score = scoreAllocPassives, } - appendFoulbornVariants(lioneyesFall, discoverFoulbornVariants("Lioneye's Fall")) + appendFoulbornVariants(lioneyesFall, "Lioneye's Fall") local intuitiveLeap = { name = "Intuitive Leap", @@ -771,11 +810,7 @@ function M.buildJewelTypes() return scoreUnallocPassives(nodes, allocNodes) end, } - do - local foulbornVariants = discoverFoulbornVariants("Intuitive Leap") - for _, variant in ipairs(foulbornVariants) do addIntuitiveLeapFoulbornFields(variant) end - appendFoulbornVariants(intuitiveLeap, foulbornVariants) - end + appendFoulbornVariants(intuitiveLeap, "Intuitive Leap") local dreamsNightmaresJewels = { { name = "The Red Dream" }, @@ -794,7 +829,7 @@ function M.buildJewelTypes() rawText = rawText, radiusIndex = getRadiusIndexFromRawText(rawText), }) - local foulbornVariants = discoverFoulbornVariants(jewelInfo.name) + local foulbornVariants = buildFoulbornVariants(jewelInfo.name) for _, variant in ipairs(foulbornVariants) do variant.variantGroup = jewelInfo.name variant.name = jewelInfo.name .. " (" .. variant.name .. ")" diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index c21cdf0f89..6958c10527 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -607,8 +607,8 @@ function RadiusJewelFinderClass:buildVariantsFromUniqueItem(uniqueName, baseName return RadiusJewelData.buildVariantsFromUniqueItem(uniqueName, baseName) end -function RadiusJewelFinderClass:discoverFoulbornVariants(uniqueName) - return RadiusJewelData.discoverFoulbornVariants(uniqueName) +function RadiusJewelFinderClass:buildFoulbornVariants(uniqueName, baseName, foulbornMap) + return RadiusJewelData.buildFoulbornVariants(uniqueName, baseName, foulbornMap) end -- ───────────────────────────────────────────────────────────────────────────── @@ -722,7 +722,8 @@ function RadiusJewelFinderClass:findEquippedJewelSockets(jewelType) for socketId, slot in pairs(self.build.itemsTab.sockets) do if allocNodes[socketId] and slot.selItemId and slot.selItemId ~= 0 then local item = self.build.itemsTab.items[slot.selItemId] - if item and item.title == jewelType.name then + local itemName = item and item.title and item.title:gsub("^[Ff]oulborn ", "") + if itemName == jewelType.name then limit = limit or item.limit t_insert(equipped, { socketId = socketId, @@ -1988,6 +1989,7 @@ end local itemTooltipLines = buildPreviewLinesForJewelType(jewelType, r.variant) local applyRawText = r.variant and r.variant.rawText or jewelType.rawText local jewelLimitKey = applyRawText and applyRawText:match("^([^\n]+)") or jewelType.name + jewelLimitKey = jewelLimitKey:gsub("^[Ff]oulborn ", "") local jewelLimit = jewelType.limit or (applyRawText and tonumber(applyRawText:match("Limited to: (%d+)"))) or nil local displayedPlans = (jewelType.name == "Intuitive Leap" or jewelType.isThread or jewelType.isImpossibleEscape) and buildDisplayedDisconnectedPassivePlans(r, points, baseline) @@ -2125,7 +2127,7 @@ end if jt.name == "Intuitive Leap" then socketResults, baseline = - self:computeIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, nil, + self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) elseif jt.isThread then socketResults, baseline = @@ -2193,7 +2195,8 @@ end local socketResults, baseline if selectedJewelType.name == "Intuitive Leap" then socketResults, baseline = - self:computeIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, selectedJewelVariant, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, + finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) elseif selectedJewelType.isThread then socketResults, baseline = self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) @@ -2442,6 +2445,7 @@ end socket = socket, score = score or 0, topNodes = topNodes, + variant = selectedJewelVariant, detailText = detailBuilder and detailBuilder(nodes, allocNodes) or nil, replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, @@ -2507,7 +2511,8 @@ end score = r.score or 0, scorePerPoint = scorePerPoint, scorePerPointSort = scorePerPointSort, - variantLabel = r.variant and (r.variant.name .. " Ring") or "", + variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.name .. " Ring") + or r.variant.dropdownLabel or r.variant.name) or "", detailText = detailText, detailNodeId = detailNodeId, topNodes = copyTableSafe(r.topNodes, false, true), From fbaa18af77b29aeeae69ece34ee67620f89dce12 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 15:41:27 +0200 Subject: [PATCH 08/52] Rebuild radius jewel specs for historic replacements --- spec/System/TestRadiusJewelFinder_spec.lua | 157 ++++++++++++++++++++- src/Classes/ItemsTab.lua | 24 ++-- src/Classes/PassiveSpec.lua | 4 +- src/Classes/RadiusJewelCompute.lua | 136 ++++++++++-------- 4 files changed, 249 insertions(+), 72 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 8b96cb175a..36987ee38a 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -732,7 +732,7 @@ describe("RadiusJewelFinder #radius-jewel", function() for _, variant in ipairs(familyVariants) do if variant.isFoulborn then foulbornCount = foulbornCount + 1 - local item = new("Item", "Rarity: Unique\n" .. variant.rawText) + local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText) assert.is_true(item.foulborn, "expected Foulborn item data for " .. variant.name) end end @@ -801,7 +801,7 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.are.equal(1, #variants) assert.are.same({ newModId }, variants[1].newModIds) - local imported = new("Item", "Rarity: Unique\n" .. variants[1].rawText) + local imported = new("Item"):Item("Rarity: Unique\n" .. variants[1].rawText) assert.is_true(imported.foulborn) assert.is_true(hasMutatedMod(imported, newModId)) end) @@ -821,7 +821,7 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_string(variant.name) assert.is_string(variant.rawText) - local imported = new("Item", "Rarity: Unique\n" .. 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)) @@ -1039,6 +1039,157 @@ describe("RadiusJewelFinder #radius-jewel", function() 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():computeBestVariantSocketImpact({ { + id = socketId, + label = "Historic socket", + pathDist = 0, + } }, { { + name = "Candidate", + rawText = MIGHT_OF_MEEK_RAW_TEXT, + } }, "Life", nil, nil, { 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: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:computeIntuitiveLeapSocketImpact( + { testSocket }, "Life", nil, "fast", { }, nil, 0, { id = "all" }, 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():computeSplitPersonalitySocketImpact({ { + id = socketId, + label = "Historic socket", + classStartDist = splitDistance, + pathDist = 0, + } }, "Life", { { + name = "Dexterity", + rawText = buildSplitPersonalityRawText("+5 to Dexterity"), + } }, nil, nil, { 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():computeSplitPersonalitySocketImpact({ { + id = testSocket.id, + label = "Stored Historic socket", + classStartDist = 42, + pathDist = 1, + } }, "Life", { { + name = "Dexterity", + rawText = buildSplitPersonalityRawText("+5 to Dexterity"), + } }, nil, nil, { id = "all" }) + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + + assert.is_false(usedComparisonSpec) + end) + + end) + -- ── computeSocketImpact (MoM / UI / AK) ──────────────────────────────── describe("computeSocketImpact", function() diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba5..6c0cbd3c08 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4150,31 +4150,39 @@ local function cloneSpecForJewelComparison(spec) return specCopy end ----@param itemsTab ItemsTab ---@param compareSlot ItemSlotControl ---@param replacementItem Item -local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementItem) +---@param allocateSocket? boolean +function ItemsTabClass:BuildSpecForJewelComparison(compareSlot, replacementItem, allocateSocket) local tempItemId - local spec = cloneSpecForJewelComparison(itemsTab.build.spec) + local spec = cloneSpecForJewelComparison(self.build.spec) 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() end, debug.traceback) if tempItemId then - itemsTab.items[tempItemId] = nil + self.items[tempItemId] = nil end if not ok then error(err, 0) @@ -4921,7 +4929,7 @@ function ItemsTabClass:AddItemStatDifferences(tooltip, item, base, slot) 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) + 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 3e64512407..5d3f64b5a0 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1075,7 +1075,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 @@ -1558,6 +1559,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 diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 2ad7a176f3..dcb9877c19 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -137,6 +137,11 @@ local function buildReplacementItem(slot) return item end +local function itemChangesPassiveTreeRadius(item) + return not not (item and item.type == "Jewel" and item.jewelData and item.jewelRadiusIndex + and (item.jewelData.conqueredBy or item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone)) +end + local function buildDisconnectedPassivePlanStep(baseOutput, baseValue, value, compareOutput, chosenNodes, variantLabel) local snapshotNodes = copyNodeList(chosenNodes) return { @@ -204,6 +209,39 @@ function Class:buildSocketReplacementContext(calcFunc, socketId) } end +function Class:socketReplacementChangesPassiveTree(replacementContext, item) + local replacedItem = replacementContext.occupancy and replacementContext.occupancy.isOccupied and replacementContext.occupancy.item + return itemChangesPassiveTreeRadius(replacedItem) or itemChangesPassiveTreeRadius(item) +end + +function Class: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. Radius 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 + spec = self.build.itemsTab:BuildSpecForJewelComparison({ nodeId = socketNode.id }, item, not socketNode.alloc) + 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 Class:getSocketDistanceToClassStart(socketId) local spec = self.build.spec local socketNode = spec.nodes[socketId] @@ -281,7 +319,7 @@ function Class:collectDisconnectedPassiveCandidates(socketNode, options) return candidates end -function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, baseOutput, baseValue, socketNode, slotName, item, impactStat, candidates, variantLabel, progressLabel, progress, maxAdditionalNodes) +function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, progressLabel, progress, maxAdditionalNodes) impactStat = normalizeImpactStat(impactStat) local addNodes = { [socketNode] = true } local function calculate(extraNode) @@ -289,11 +327,7 @@ function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, baseOutput, bas if extraNode then nextNodes[extraNode] = true end - local output = calcFunc({ - addNodes = nextNodes, - repSlotName = slotName, - repItem = item, - }) + local output = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, nextNodes)) return output, self:getImpactValue(impactStat, output) end @@ -344,16 +378,14 @@ function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, baseOutput, bas return result end -function Class:computeDisconnectedPassiveFastPlan(calcFunc, baseOutput, baseValue, socketNode, slotName, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold) +function Class:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold) impactStat = normalizeImpactStat(impactStat) local jewelOnlyOutput, jewelOnlyValue local function ensureJewelOnly() if not jewelOnlyOutput then - jewelOnlyOutput = calcFunc({ - addNodes = { [socketNode] = true }, - repSlotName = slotName, - repItem = item, - }) + jewelOnlyOutput = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, { + [socketNode] = true, + })) jewelOnlyValue = self:getImpactValue(impactStat, jewelOnlyOutput) end end @@ -368,11 +400,10 @@ function Class:computeDisconnectedPassiveFastPlan(calcFunc, baseOutput, baseValu local delta = deltaCache[node.id] if delta == nil then ensureJewelOnly() - local output = calcFunc({ - addNodes = { [socketNode] = true, [node] = true }, - repSlotName = slotName, - repItem = item, - }) + local output = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, { + [socketNode] = true, + [node] = true, + })) delta = self:getImpactValue(impactStat, output) - jewelOnlyValue deltaCache[node.id] = delta end @@ -411,11 +442,7 @@ function Class:computeDisconnectedPassiveFastPlan(calcFunc, baseOutput, baseValu end if skipPlanSteps then - local finalOutput = calcFunc({ - addNodes = addNodes, - repSlotName = slotName, - repItem = item, - }) + local finalOutput = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, addNodes)) local finalValue = self:getImpactValue(impactStat, finalOutput) return buildDisconnectedPassivePlanStep(baseOutput, baseValue, finalValue, finalOutput, chosenNodes, variantLabel) end @@ -427,11 +454,7 @@ function Class:computeDisconnectedPassiveFastPlan(calcFunc, baseOutput, baseValu for _, node in ipairs(chosenNodes) do t_insert(prefixNodes, node) prefixAddNodes[node] = true - lastOutput = calcFunc({ - addNodes = prefixAddNodes, - repSlotName = slotName, - repItem = item, - }) + lastOutput = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, prefixAddNodes)) lastValue = self:getImpactValue(impactStat, lastOutput) t_insert(planSteps, buildDisconnectedPassivePlanStep(baseOutput, baseValue, lastValue, lastOutput, prefixNodes, variantLabel)) end @@ -458,14 +481,11 @@ function Class:computeSocketImpact(sockets, rawText, impactStat, progress, maxTo local socketBasePoints = self:getSocketBasePoints(socket, occupancy) if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) - local slotName = replacementContext.slotName local item = new("Item"):Item("Rarity: Unique\n" .. rawText) item:BuildModList() - local output = calcFunc({ - addNodes = { [replacementContext.socketNode] = true }, - repSlotName = slotName, - repItem = item, - }) + 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, { @@ -497,18 +517,15 @@ function Class:computeBestVariantSocketImpact(sockets, variants, impactStat, pro local socketBasePoints = self:getSocketBasePoints(socket, occupancy) if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) - local slotName = replacementContext.slotName local socketNode = replacementContext.socketNode local bestResult for variantIndex, variant in ipairs(variants) do progressTick(socketProgress, variantIndex, #variants, socket.label .. " | " .. variant.name) local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText) item:BuildModList() - local output = calcFunc({ - addNodes = { [socketNode] = true }, - repSlotName = slotName, - repItem = item, - }) + 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 @@ -585,7 +602,6 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) local socketNode = replacementContext.socketNode - local slotName = replacementContext.slotName local item = new("Item"):Item("Rarity: Unique\n" .. rawText) item:BuildModList() local candidates = self:collectDisconnectedPassiveCandidates(socketNode, candidateOptions) @@ -594,11 +610,11 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) local result if methodId == "fast" then - local cacheKey = s_format("IL|%s|%s", statField, variantKey) + local cacheKey = s_format("IL|%s|%s|%s", statField, variantKey, socket.id) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, planCache[cacheKey], socket.label, socketProgress, maxAdditionalNodes, skipPlanSteps) + result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, nil, planCache[cacheKey], socket.label, socketProgress, maxAdditionalNodes, skipPlanSteps) else - result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, socket.label, socketProgress, maxAdditionalNodes) + result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, nil, socket.label, socketProgress, maxAdditionalNodes) end result.socket = socket result.variant = variant @@ -672,7 +688,6 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id) local socketNode = replacementContext.socketNode - local slotName = replacementContext.slotName local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) local bestResult local bestVariantIndex, bestCandidates @@ -688,11 +703,11 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian local earlyPruneThreshold = bestResult and bestResult.delta or nil local result if methodId == "fast" then - local cacheKey = s_format("ThreadOfHope|%s", statField) + local cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, threadVariant.name .. " Ring", planCache[cacheKey], socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes, true, earlyPruneThreshold) + result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, threadVariant.name .. " Ring", planCache[cacheKey], socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes, true, earlyPruneThreshold) else - result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, threadVariant.name .. " Ring", socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes) + result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, threadVariant.name .. " Ring", socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes) end if not result.pruned then result.variant = threadVariant @@ -719,6 +734,7 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian bestVariantIndex = bestVariantIndex, bestCandidates = bestCandidates, socketBasePoints = socketBasePoints, + cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id), resultIndex = #results, }) end @@ -749,18 +765,17 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian end local replacementContext = pending.replacementContext local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - pending.socketBasePoints, 0) or nil - local cacheKey = s_format("ThreadOfHope|%s", statField) local fullResult = self:computeDisconnectedPassiveFastPlan( calcFunc, + replacementContext, replacementContext.baselineOutput, pending.socketBaseline, replacementContext.socketNode, - replacementContext.slotName, threadItems[pending.bestVariantIndex], impactStat, pending.bestCandidates, threadVariants[pending.bestVariantIndex].name .. " Ring", - planCache[cacheKey], + planCache[pending.cacheKey], nil, nil, maxAdditionalNodes, @@ -815,11 +830,13 @@ function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants progressTick(socketProgress, variantIdx, #variants, socket.label .. " | " .. variant.name) local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText) item:BuildModList() - local output = calcFunc({ - addNodes = { [socketNode] = true }, - repSlotName = slotName, - repItem = item, + local override = self:buildSocketReplacementOverride(replacementContext, item, { + [socketNode] = true, }) + if override.spec then + override.spec.nodes[socketNode.id].distanceToClassStart = splitDistance + end + local output = calcFunc(override) local value = self:getImpactValue(impactStat, output) local delta = self:calculateImpactDelta(impactStat, baselineOutput, output) if not bestResult or delta > bestResult.delta then @@ -950,7 +967,6 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants local representativeSocket = groupEntry.representativeSocket local replacementContext = self:buildSocketReplacementContext(calcFunc, representativeSocket.id) local representativeSocketNode = replacementContext.socketNode - local representativeSlotName = replacementContext.slotName local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) local bestResult for _, variant in ipairs(variants) do @@ -962,14 +978,14 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants local earlyPruneThreshold = bestResult and bestResult.delta or nil local result if methodId == "fast" then - local cacheKey = s_format("IE|%s|%s", statField, variant.name) + local cacheKey = s_format("IE|%s|%s|%s", statField, variant.name, representativeSocket.id) planCache[cacheKey] = planCache[cacheKey] or { } result = self:computeDisconnectedPassiveFastPlan( calcFunc, + replacementContext, replacementContext.baselineOutput, socketBaseline, representativeSocketNode, - representativeSlotName, variantData.item, impactStat, variantData.candidates, @@ -984,10 +1000,10 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants else result = self:computeDisconnectedPassiveSimulatedPlan( calcFunc, + replacementContext, replacementContext.baselineOutput, socketBaseline, representativeSocketNode, - representativeSlotName, variantData.item, impactStat, variantData.candidates, @@ -1049,13 +1065,13 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants local replacementContext = self:buildSocketReplacementContext(calcFunc, groupEntry.representativeSocket.id) local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil - local cacheKey = s_format("IE|%s|%s", statField, topResult.variant.name) + local cacheKey = s_format("IE|%s|%s|%s", statField, topResult.variant.name, groupEntry.representativeSocket.id) local fullResult = self:computeDisconnectedPassiveFastPlan( calcFunc, + replacementContext, replacementContext.baselineOutput, socketBaseline, replacementContext.socketNode, - replacementContext.slotName, variantData.item, impactStat, variantData.candidates, From 9c74cdcbc2cddf939a2efa558f76e44e1699f073 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 17:40:07 +0200 Subject: [PATCH 09/52] Reuse ordinary Impossible Escape cache --- spec/System/TestRadiusJewelFinder_spec.lua | 90 ++++++++++++++++++++++ src/Classes/RadiusJewelCompute.lua | 14 +++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 36987ee38a..b405f29821 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -1420,6 +1420,96 @@ describe("RadiusJewelFinder #radius-jewel", function() return makeFinder():buildJewelSockets(getLargeRadiusIndex()) end + it("shares fast cache keys except for structural jewel replacements", function() + local finder = makeFinder() + local sharedKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + socketNode = { id = 36634 }, + occupancy = { isOccupied = false }, + }) + local structuralItem = { + type = "Jewel", + jewelData = { conqueredBy = true }, + jewelRadiusIndex = getLargeRadiusIndex(), + } + local firstStructuralKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + socketNode = { id = 36634 }, + occupancy = { isOccupied = true, item = structuralItem }, + }) + local secondStructuralKey = finder: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.collectDisconnectedPassiveCandidates + local originalBuildOverride = finder.buildSocketReplacementOverride + local originalCacheKey = finder.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.collectDisconnectedPassiveCandidates = function() + return { + { id = -101, name = "First" }, + { id = -102, name = "Second" }, + { id = -103, name = "Third" }, + } + end + finder.buildSocketReplacementOverride = function(_, _, _, addNodes) + return { addNodes = addNodes } + end + + local function countCalculations(cacheKeyFunc) + finder.getImpossibleEscapePlanCacheKey = cacheKeyFunc + calculationCount = 0 + finder:computeImpossibleEscapeSocketImpact(sockets, "Life", { variant }, "fast", { }, nil, 2, nil, 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.collectDisconnectedPassiveCandidates = originalCollectCandidates + finder.buildSocketReplacementOverride = originalBuildOverride + finder.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") diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index dcb9877c19..f75a82b20f 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -214,6 +214,16 @@ function Class:socketReplacementChangesPassiveTree(replacementContext, item) return itemChangesPassiveTreeRadius(replacedItem) or itemChangesPassiveTreeRadius(item) end +function Class:getImpossibleEscapePlanCacheKey(statField, variantName, replacementContext) + local cacheKey = s_format("IE|%s|%s", statField, variantName) + local occupancy = replacementContext.occupancy + if occupancy and occupancy.isOccupied and itemChangesPassiveTreeRadius(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 Class:buildSocketReplacementOverride(replacementContext, item, addNodes) local override = { addNodes = addNodes, @@ -978,7 +988,7 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants local earlyPruneThreshold = bestResult and bestResult.delta or nil local result if methodId == "fast" then - local cacheKey = s_format("IE|%s|%s|%s", statField, variant.name, representativeSocket.id) + local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, variant.name, replacementContext) planCache[cacheKey] = planCache[cacheKey] or { } result = self:computeDisconnectedPassiveFastPlan( calcFunc, @@ -1065,7 +1075,7 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants local replacementContext = self:buildSocketReplacementContext(calcFunc, groupEntry.representativeSocket.id) local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil - local cacheKey = s_format("IE|%s|%s|%s", statField, topResult.variant.name, groupEntry.representativeSocket.id) + local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, topResult.variant.name, replacementContext) local fullResult = self:computeDisconnectedPassiveFastPlan( calcFunc, replacementContext, From ad226d1d43199287a69c090878576872b58b735e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 17:52:52 +0200 Subject: [PATCH 10/52] Show replaced jewels in Apply tooltip --- spec/System/TestRadiusJewelFinder_spec.lua | 39 ++++++++++++++++++++++ src/Classes/RadiusJewelFinder.lua | 6 ++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index b405f29821..e9e6a045e3 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -1190,6 +1190,45 @@ describe("RadiusJewelFinder #radius-jewel", function() end) + describe("Apply tooltip", function() + + it("names a jewel that Apply will replace", function() + while main.popups[1] do + main:ClosePopup() + end + local popup = makeFinder():Open() + local function assertReplacementTooltip(row, jewelName) + popup.controls.resultsList.list = { row } + popup.controls.resultsList.selIndex = 1 + + local tooltip = new("Tooltip") + popup.controls.applyButton.tooltipFunc(tooltip) + local lines = { } + for _, line in ipairs(tooltip.lines) do + if line.text and line.text ~= "" then + table.insert(lines, line.text) + end + end + assert.is_true(table.concat(lines, "\n"):find(jewelName, 1, true) ~= nil, + "expected Apply tooltip to identify the jewel it replaces") + end + + assertReplacementTooltip({ + applyRawText = MIGHT_OF_MEEK_RAW_TEXT, + jewelName = "Might of the Meek", + socketLabel = "Test socket", + replacedItemLabel = "Unnatural Instinct", + }, "Unnatural Instinct") + assertReplacementTooltip({ + applyRawText = MIGHT_OF_MEEK_RAW_TEXT, + jewelName = "Might of the Meek", + socketLabel = "Test socket", + storedUnallocatedItemLabel = "Thread of Hope", + }, "Thread of Hope") + end) + + end) + -- ── computeSocketImpact (MoM / UI / AK) ──────────────────────────────── describe("computeSocketImpact", function() diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 6958c10527..2139cd267c 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -2588,8 +2588,10 @@ end tooltip:Clear(true) tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket")) tooltip:AddLine(16, "^8Adds the jewel to this build.") - if row.storedUnallocatedItemLabel then - tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.") + if row.replacedItemLabel then + tooltip:AddLine(16, "^xFFAA33Replaces equipped jewel: ^7" .. row.replacedItemLabel) + elseif row.storedUnallocatedItemLabel then + tooltip:AddLine(16, "^xFFAA33Replaces stored jewel: ^7" .. row.storedUnallocatedItemLabel) end tooltip:AddLine(16, "^8Double-click a result to apply it.") end From 1f1892a40f70afea946f3e41910ccecff4583f58 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 18:12:02 +0200 Subject: [PATCH 11/52] Show replaced jewel tooltips --- spec/System/TestRadiusJewelFinder_spec.lua | 55 +++++++++++----------- src/Classes/RadiusJewelFinder.lua | 41 +++++++++++----- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index e9e6a045e3..49acb4faad 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -1190,41 +1190,40 @@ describe("RadiusJewelFinder #radius-jewel", function() end) - describe("Apply tooltip", function() + describe("replacement item tooltip", function() - it("names a jewel that Apply will replace", function() + it("attaches the replaced jewel to its detail line", function() while main.popups[1] do main:ClosePopup() end local popup = makeFinder():Open() - local function assertReplacementTooltip(row, jewelName) - popup.controls.resultsList.list = { row } - popup.controls.resultsList.selIndex = 1 + 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, + sortPctPerPoint = 0, + detailText = "", + action = "replace", + replacedItemLabel = "Existing jewel", + }, + }, "") - local tooltip = new("Tooltip") - popup.controls.applyButton.tooltipFunc(tooltip) - local lines = { } - for _, line in ipairs(tooltip.lines) do - if line.text and line.text ~= "" then - table.insert(lines, line.text) - end + 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 - assert.is_true(table.concat(lines, "\n"):find(jewelName, 1, true) ~= nil, - "expected Apply tooltip to identify the jewel it replaces") - end - - assertReplacementTooltip({ - applyRawText = MIGHT_OF_MEEK_RAW_TEXT, - jewelName = "Might of the Meek", - socketLabel = "Test socket", - replacedItemLabel = "Unnatural Instinct", - }, "Unnatural Instinct") - assertReplacementTooltip({ - applyRawText = MIGHT_OF_MEEK_RAW_TEXT, - jewelName = "Might of the Meek", - socketLabel = "Test socket", - storedUnallocatedItemLabel = "Thread of Hope", - }, "Thread of Hope") + end + + assert.is_not_nil(replacementLine, "expected a replacement detail line") + assert.are.equal(replacedItem, replacementLine.item) end) end) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 2139cd267c..d1ab8d8e2d 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -174,6 +174,7 @@ function RadiusJewelDetailListClass:RadiusJewelDetailListControl(anchor, rect, c self.build = build self.socketViewer = socketViewer self.nodeTooltip = new("Tooltip"):Tooltip() + self.itemTooltip = new("Tooltip"):Tooltip() return self end @@ -200,11 +201,7 @@ end function RadiusJewelDetailListClass:Draw(viewPort) self.TextListControl.Draw(self, viewPort) local hoverLine = self:GetHoverLine() - if not hoverLine or not hoverLine.nodeId or main.popups[2] then - return - end - local node = self.build.spec.nodes[hoverLine.nodeId] or self.build.spec.tree.nodes[hoverLine.nodeId] - if not node then + if not hoverLine or main.popups[2] then return end @@ -240,6 +237,23 @@ function RadiusJewelDetailListClass:Draw(viewPort) end local cursorX, cursorY = GetCursorPos() + if hoverLine.item then + SetDrawLayer(nil, 100) + self.itemTooltip:Clear(true) + self.build.itemsTab:AddItemTooltip(self.itemTooltip, hoverLine.item) + local ttW, ttH = self.itemTooltip:GetSize() + local ttX, ttY = placeTooltip(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 @@ -1443,20 +1457,25 @@ end if row.variantLabel and row.variantLabel ~= "" then t_insert(resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel }) end + local replacementItem + if row.replacedItemLabel or row.storedUnallocatedItemLabel then + local occupancy = self:getSocketOccupancyInfo(row.socketId) + replacementItem = occupancy and occupancy.item + end if row.action == "keep" then t_insert(resultDetailListData, { height = 16, [1] = "^8Already equipped" }) elseif row.action == "moveReplace" then t_insert(resultDetailListData, { height = 16, [1] = "^xBB88FFMove equipped jewel" }) - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. (row.replacedItemLabel or "?") }) + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. (row.replacedItemLabel or "?"), item = replacementItem }) elseif row.action == "move" then t_insert(resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" }) elseif row.replacedItemLabel then t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" }) - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. row.replacedItemLabel }) + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. row.replacedItemLabel, item = replacementItem }) elseif row.storedUnallocatedItemLabel then t_insert(resultDetailListData, { height = 16, [1] = "^2Use unallocated socket" }) t_insert(resultDetailListData, { height = 16, [1] = "^8Stored jewel ignored until this socket is allocated." }) - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Apply will replace the stored jewel: ^7" .. row.storedUnallocatedItemLabel }) + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Apply will replace the stored jewel: ^7" .. row.storedUnallocatedItemLabel, item = replacementItem }) else t_insert(resultDetailListData, { height = 16, [1] = "^2Use free socket" }) end @@ -2588,10 +2607,8 @@ end tooltip:Clear(true) tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket")) tooltip:AddLine(16, "^8Adds the jewel to this build.") - if row.replacedItemLabel then - tooltip:AddLine(16, "^xFFAA33Replaces equipped jewel: ^7" .. row.replacedItemLabel) - elseif row.storedUnallocatedItemLabel then - tooltip:AddLine(16, "^xFFAA33Replaces stored jewel: ^7" .. row.storedUnallocatedItemLabel) + if row.storedUnallocatedItemLabel then + tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.") end tooltip:AddLine(16, "^8Double-click a result to apply it.") end From 3e8bcc1d64699ad855bc106de4951ad2162db8cc Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 19:17:35 +0200 Subject: [PATCH 12/52] Rebuild cluster trees for jewel comparisons Ensure All occupied scores a replacement after removing the cluster's allocated passives. --- spec/System/TestRadiusJewelFinder_spec.lua | 59 ++++++++++++++++++++++ src/Classes/ItemsTab.lua | 29 +++++++++-- src/Classes/RadiusJewelCompute.lua | 15 ++++-- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 49acb4faad..aa904bc1d0 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -5,6 +5,7 @@ -- All other sockets are unallocated and empty. local occVortex = LoadModule("../spec/TestBuilds/3.13/OccVortex.lua") +local mirageArcherToxicRain = LoadModule("../spec/TestBuilds/3.13/Mirage Archer Toxic Rain.lua") local RadiusJewelData = LoadModule("Classes/RadiusJewelData") local MIGHT_OF_MEEK_RAW_TEXT = [[Might of the Meek @@ -1452,6 +1453,64 @@ describe("RadiusJewelFinder #radius-jewel", function() 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():computeBestVariantSocketImpact({ { + id = socketId, + label = "Cluster socket", + pathDist = 0, + } }, { { + name = "Candidate", + rawText = MIGHT_OF_MEEK_RAW_TEXT, + } }, "Life", nil, nil, { 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() diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 6c0cbd3c08..7931d7b3ec 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4097,7 +4097,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. @@ -4146,6 +4146,22 @@ 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 @@ -4153,9 +4169,10 @@ end ---@param compareSlot ItemSlotControl ---@param replacementItem Item ---@param allocateSocket? boolean -function ItemsTabClass:BuildSpecForJewelComparison(compareSlot, replacementItem, allocateSocket) +---@param rebuildClusterJewelGraphs? boolean +function ItemsTabClass:BuildSpecForJewelComparison(compareSlot, replacementItem, allocateSocket, rebuildClusterJewelGraphs) local tempItemId - local spec = cloneSpecForJewelComparison(self.build.spec) + local spec = cloneSpecForJewelComparison(self.build.spec, rebuildClusterJewelGraphs) if replacementItem then if replacementItem.id and self.items[replacementItem.id] == replacementItem then spec.jewels[compareSlot.nodeId] = replacementItem.id @@ -4179,7 +4196,11 @@ function ItemsTabClass:BuildSpecForJewelComparison(compareSlot, replacementItem, end local ok, err = xpcall(function() - spec:BuildAllDependsAndPaths() + if rebuildClusterJewelGraphs then + spec:BuildClusterJewelGraphs() + else + spec:BuildAllDependsAndPaths() + end end, debug.traceback) if tempItemId then self.items[tempItemId] = nil diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index f75a82b20f..5a8ef61446 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -138,8 +138,11 @@ local function buildReplacementItem(slot) end local function itemChangesPassiveTreeRadius(item) - return not not (item and item.type == "Jewel" and item.jewelData and item.jewelRadiusIndex - and (item.jewelData.conqueredBy or item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone)) + return not not (item and item.type == "Jewel" and ( + item.clusterJewel + or (item.jewelData and item.jewelRadiusIndex + and (item.jewelData.conqueredBy or item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone)) + )) end local function buildDisconnectedPassivePlanStep(baseOutput, baseValue, value, compareOutput, chosenNodes, variantLabel) @@ -231,13 +234,15 @@ function Class:buildSocketReplacementOverride(replacementContext, item, addNodes repItem = item, } if self:socketReplacementChangesPassiveTree(replacementContext, item) then - -- repItem changes only the evaluated item. Radius jewels can also change - -- node ownership and dependencies, so rebuild a comparison spec first. + -- 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 - spec = self.build.itemsTab:BuildSpecForJewelComparison({ nodeId = socketNode.id }, item, not socketNode.alloc) + 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 From aa7bd790258d59b54fc4939a8befefa303125611 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 20:07:11 +0200 Subject: [PATCH 13/52] Label replaced jewels by base type --- spec/System/TestRadiusJewelFinder_spec.lua | 10 ++++++++++ src/Classes/RadiusJewelFinder.lua | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index aa904bc1d0..dc2782616a 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -2027,6 +2027,16 @@ describe("RadiusJewelFinder #radius-jewel", function() 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] + equipFakeJewel(socketId, "Chimeric Creed", nil, { baseName = "Crimson Jewel" }) + local finder = makeFinder() + local isAllowed, occupancy = finder:socketMatchesOccupiedMode(socketId, { id = "safe" }) + + assert.is_true(isAllowed) + assert.are.equal("Chimeric Creed (Crimson Jewel)", occupancy.replacedItemLabel) + 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" }) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index d1ab8d8e2d..ec2b64538c 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -673,7 +673,12 @@ function RadiusJewelFinderClass:getSocketOccupancyInfo(socketId) } end local item = self.build.itemsTab.items[slot.selItemId] - local itemLabel = item and (item.title or item.name or item.baseName) or "Unknown item" + 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, From b0b74ef3cab4d50c7cf641f5464d3202127a40f7 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 20:22:28 +0200 Subject: [PATCH 14/52] Allow ordinary jewels in safe sockets --- spec/System/TestRadiusJewelFinder_spec.lua | 8 +++++++- src/Classes/RadiusJewelFinder.lua | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index dc2782616a..f8cd20704e 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -2029,10 +2029,16 @@ describe("RadiusJewelFinder #radius-jewel", function() it("allows ordinary jewels in Safe occupied and labels their base type", function() local socketId = ALLOC_SOCKET_IDS[1] - equipFakeJewel(socketId, "Chimeric Creed", nil, { baseName = "Crimson Jewel" }) + local itemId = 999000 + socketId + local item = new("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) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index ec2b64538c..c049cb476f 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -692,9 +692,10 @@ function RadiusJewelFinderClass:getSocketOccupancyInfo(socketId) end local isPositionSensitive = false if item then + local impossibleEscapeKeystones = item.jewelData and item.jewelData.impossibleEscapeKeystones isPositionSensitive = item.clusterJewel or item.jewelRadiusIndex ~= nil - or (item.jewelData and item.jewelData.impossibleEscapeKeystones ~= nil) + or (impossibleEscapeKeystones and next(impossibleEscapeKeystones) ~= nil) or (item.title and item.title:match("^Split Personality") ~= nil) end return { From 733f79e43836c116c864a8be89d89dc30b23eae6 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 21:11:40 +0200 Subject: [PATCH 15/52] Label special radius jewel sockets --- spec/System/TestRadiusJewelFinder_spec.lua | 10 ++++++++++ src/Classes/RadiusJewelFinder.lua | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index f8cd20704e..826a4d3e8f 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -187,6 +187,16 @@ describe("RadiusJewelFinder #radius-jewel", function() 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 } diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index c049cb476f..66d672a61c 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -80,6 +80,13 @@ local ACTION_COLORS = { replace = "^xFFAA33", keep = "^8", } +-- 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", +} + local function colorSocketLabel(row) return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel end @@ -635,10 +642,10 @@ function RadiusJewelFinderClass:buildJewelSockets(largeRadiusIndex) local sockets = { } for socketId, socketData in pairs(self.build.spec.nodes) do if socketData.isJewelSocket and socketData.name ~= "Charm Socket" then - local keystone = "Unknown" + local keystone = SOCKET_ZONE_NAMES[socketId] or "Unknown" local minDist = m_huge local socketNode = treeData.nodes[socketId] - if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[largeRadiusIndex] then + 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 From 0a987bf150eec16c0ec0a0bbdec7609516a05c38 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 3 Aug 2026 22:52:28 +0200 Subject: [PATCH 16/52] Handle Abyss Timeless replacements --- spec/System/TestRadiusJewelFinder_spec.lua | 20 +++++++++++++++++++- src/Classes/RadiusJewelCompute.lua | 5 +++-- src/Classes/RadiusJewelFinder.lua | 4 +++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 826a4d3e8f..dc134ff093 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -1536,7 +1536,6 @@ describe("RadiusJewelFinder #radius-jewel", function() local structuralItem = { type = "Jewel", jewelData = { conqueredBy = true }, - jewelRadiusIndex = getLargeRadiusIndex(), } local firstStructuralKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { socketNode = { id = 36634 }, @@ -2053,6 +2052,25 @@ describe("RadiusJewelFinder #radius-jewel", function() 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: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" }) diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 5a8ef61446..6c3100e571 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -140,8 +140,9 @@ end local function itemChangesPassiveTreeRadius(item) return not not (item and item.type == "Jewel" and ( item.clusterJewel - or (item.jewelData and item.jewelRadiusIndex - and (item.jewelData.conqueredBy or item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone)) + or (item.jewelData and (item.jewelData.conqueredBy + or item.jewelRadiusIndex + and (item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone))) )) end diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 66d672a61c..10a47b07d7 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -699,8 +699,10 @@ function RadiusJewelFinderClass:getSocketOccupancyInfo(socketId) end local isPositionSensitive = false if item then - local impossibleEscapeKeystones = item.jewelData and item.jewelData.impossibleEscapeKeystones + 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) From b9686ced75ecf964ca61e3378a44c345386e4205 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 5 Aug 2026 15:44:22 +0200 Subject: [PATCH 17/52] Regenerate manifest after Radius Jewel rebase Refresh generated hashes after resolving manifest conflicts against origin/dev. --- manifest.xml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/manifest.xml b/manifest.xml index f1b269dca2..032ee51285 100644 --- a/manifest.xml +++ b/manifest.xml @@ -150,7 +150,7 @@ - + @@ -162,7 +162,7 @@ - + @@ -171,6 +171,9 @@ + + + @@ -193,7 +196,7 @@ - + From e05a2d9fb78451b66db57d1e370b76769a5391f6 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 15 Aug 2026 16:55:14 +0200 Subject: [PATCH 18/52] Adapt radius jewel constructors to current class syntax --- manifest.xml | 14 +++++++------- spec/System/TestRadiusJewelFinder_spec.lua | 2 +- src/Classes/RadiusJewelData.lua | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/manifest.xml b/manifest.xml index 032ee51285..8b47af6237 100644 --- a/manifest.xml +++ b/manifest.xml @@ -150,7 +150,7 @@ - + @@ -162,7 +162,7 @@ - + @@ -171,9 +171,9 @@ - - - + + + @@ -196,7 +196,7 @@ - + @@ -1397,4 +1397,4 @@ - \ No newline at end of file + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index dc134ff093..85ba7cfd00 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -2039,7 +2039,7 @@ describe("RadiusJewelFinder #radius-jewel", function() 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", "Rarity: RARE\nChimeric Creed\nCrimson Jewel\n") + 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 diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 10f40a03b9..acc2efcb76 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -374,7 +374,7 @@ local function buildFoulbornVariants(uniqueName, baseName, foulbornMap) local combinationCount = 2 ^ #mutationPairs - 1 local baseRawText = mustGetCurrentUniqueRawText(uniqueName, baseName) for combination = 1, combinationCount do - local item = new("Item", "Rarity: Unique\n" .. baseRawText) + 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 From 9d114d16ab31e4513b2410cab530f161db1b4ec7 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 10:15:01 +0200 Subject: [PATCH 19/52] Share passive tree comparison classification Reuse ItemsTab's main-tree comparison predicate in Radius Jewel computations while keeping Cluster Jewel rebuilds explicit. --- manifest.xml | 4 ++-- src/Classes/ItemsTab.lua | 8 ++++++-- src/Classes/RadiusJewelCompute.lua | 15 ++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/manifest.xml b/manifest.xml index 8b47af6237..6b79718bbb 100644 --- a/manifest.xml +++ b/manifest.xml @@ -150,7 +150,7 @@ - + @@ -171,7 +171,7 @@ - + diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 7931d7b3ec..5ad64f044c 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4068,7 +4068,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))) @@ -4949,7 +4950,10 @@ function ItemsTabClass:AddItemStatDifferences(tooltip, item, base, slot) 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 + if compareSlot.nodeId and ( + self:ItemNeedsMainTreeComparisonSpec(selItem) + or self:ItemNeedsMainTreeComparisonSpec(item) + ) then override.spec = self:BuildSpecForJewelComparison(compareSlot, override.repItem) end local output = calcFunc(override) diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 6c3100e571..18d64c466e 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -137,13 +137,9 @@ local function buildReplacementItem(slot) return item end -local function itemChangesPassiveTreeRadius(item) - return not not (item and item.type == "Jewel" and ( - item.clusterJewel - or (item.jewelData and (item.jewelData.conqueredBy - or item.jewelRadiusIndex - and (item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone))) - )) +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) @@ -215,13 +211,14 @@ end function Class:socketReplacementChangesPassiveTree(replacementContext, item) local replacedItem = replacementContext.occupancy and replacementContext.occupancy.isOccupied and replacementContext.occupancy.item - return itemChangesPassiveTreeRadius(replacedItem) or itemChangesPassiveTreeRadius(item) + return itemNeedsRadiusComparisonSpec(self.build.itemsTab, replacedItem) + or itemNeedsRadiusComparisonSpec(self.build.itemsTab, item) end function Class:getImpossibleEscapePlanCacheKey(statField, variantName, replacementContext) local cacheKey = s_format("IE|%s|%s", statField, variantName) local occupancy = replacementContext.occupancy - if occupancy and occupancy.isOccupied and itemChangesPassiveTreeRadius(occupancy.item) then + 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 From fa2d836d7a3ed81707e3fda4d49c1db934214934 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 10:33:18 +0200 Subject: [PATCH 20/52] Extract radius jewel result controls Keep the finder focused on popup orchestration while loading result and detail controls through their class-named modules. --- manifest.xml | 4 +- src/Classes/RadiusJewelDetailListControl.lua | 131 +++++ src/Classes/RadiusJewelFinder.lua | 494 ------------------ src/Classes/RadiusJewelResultsListControl.lua | 380 ++++++++++++++ 4 files changed, 514 insertions(+), 495 deletions(-) create mode 100644 src/Classes/RadiusJewelDetailListControl.lua create mode 100644 src/Classes/RadiusJewelResultsListControl.lua diff --git a/manifest.xml b/manifest.xml index 6b79718bbb..a2f2b6c862 100644 --- a/manifest.xml +++ b/manifest.xml @@ -173,7 +173,9 @@ - + + + diff --git a/src/Classes/RadiusJewelDetailListControl.lua b/src/Classes/RadiusJewelDetailListControl.lua new file mode 100644 index 0000000000..7d0356d80e --- /dev/null +++ b/src/Classes/RadiusJewelDetailListControl.lua @@ -0,0 +1,131 @@ +-- Path of Building +-- +-- Class: Radius Jewel Detail List Control +-- Displays result details with passive-node and item previews. +-- + +local ipairs = ipairs + +---@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 function clampRectPosition(x, y, width, height) + x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) + y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) + return x, y + end + local function 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 + local function placeTooltip(ttW, ttH, cursorX, cursorY, blockedRectangles) + local candidates = { + { x = cursorX + 20, y = cursorY + 20 }, + { x = cursorX - ttW - 20, y = cursorY + 20 }, + { x = cursorX + 20, y = cursorY - ttH - 20 }, + { x = cursorX - ttW - 20, y = cursorY - ttH - 20 }, + } + for _, candidate in ipairs(candidates) do + local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH) + local overlaps = false + for _, blockedRect in ipairs(blockedRectangles or { }) do + if rectanglesOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then + overlaps = true + break + end + end + if not overlaps then + return ttX, ttY + end + end + return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) + end + + local cursorX, cursorY = GetCursorPos() + if hoverLine.item then + SetDrawLayer(nil, 100) + self.itemTooltip:Clear(true) + self.build.itemsTab:AddItemTooltip(self.itemTooltip, hoverLine.item) + local ttW, ttH = self.itemTooltip:GetSize() + local ttX, ttY = placeTooltip(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) + self.nodeTooltip:Clear(true) + local prevShowStatDifferences = self.socketViewer.showStatDifferences + self.socketViewer.showStatDifferences = true + self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build) + self.socketViewer.showStatDifferences = prevShowStatDifferences + local ttW, ttH = self.nodeTooltip:GetSize() + local ttX, ttY = placeTooltip(ttW, ttH, cursorX, cursorY, { viewerRect }) + self.nodeTooltip:Draw(ttX, ttY, nil, nil, viewPort) + SetDrawLayer(nil, 0) +end diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 10a47b07d7..c46e1effac 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -54,32 +54,6 @@ local function extractTooltipStats(output) return out end -local function formatSignedValue(value) - local sign = value >= 0 and "+" or "" - local col = value > 0 and "^2" or (value < 0 and "^1" or "^8") - return s_format("%s%s%.1f", col, sign, value) -end - -local function formatSignedPercent(value) - local sign = value >= 0 and "+" or "" - local col = value > 0 and "^2" or (value < 0 and "^1" or "^8") - return s_format("%s%s%.1f%%", col, sign, value) -end - -local function formatPerPointDisplay(value, points) - if points == 0 then - return value > 0 and "^2Free" or (value < 0 and "^1Free" or "^8Free") - end - return formatSignedPercent(value) -end - -local ACTION_COLORS = { - new = "^2", - move = "^x33AAFF", - moveReplace = "^xBB88FF", - replace = "^xFFAA33", - keep = "^8", -} -- These sockets have no nearby Keystone. Keep the labels used by the Timeless Jewel finder. local SOCKET_ZONE_NAMES = { [26725] = "Marauder", @@ -87,474 +61,6 @@ local SOCKET_ZONE_NAMES = { [7960] = "Templar/Witch", } -local function colorSocketLabel(row) - return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel -end - -local RESULT_DETAIL_COLUMN_BY_MODE = { - computeSocket = 6, - computeSocketAll = 7, - find = 5, - findThread = 6, -} -local RESULT_SOCKET_COLUMN_BY_MODE = { - computeSocket = 1, - computeSocketAll = 2, - find = 1, - findThread = 1, -} -local RESULT_STAT_COLUMNS_BY_MODE = { - computeSocket = { [3] = true, [4] = true, [5] = true }, - computeSocketAll = { [4] = true, [5] = true, [6] = true }, -} -local RESULT_ITEM_COLUMNS_BY_MODE = { - computeSocket = { [6] = true }, - computeSocketAll = { [7] = true }, - find = { [5] = true }, - findThread = { [6] = true }, -} - ----@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 = { - { width = rect[3] - 22, label = "" }, - }, - computeSocket = { - { width = 170, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, - { width = 75, label = "Gain", sortable = true }, - { width = 60, label = "%", sortable = true }, - { width = 65, label = "%/Pt", sortable = true }, - { width = 150, label = "Detail", sortable = true }, - }, - computeSocketAll = { - { width = 120, label = "Jewel", sortable = true }, - { width = 130, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, - { width = 75, label = "Gain", sortable = true }, - { width = 60, label = "%", sortable = true }, - { width = 65, label = "%/Pt", sortable = true }, - { width = 70, label = "Detail", sortable = true }, - }, - find = { - { width = 170, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, - { width = 60, label = "Score", sortable = true }, - { width = 70, label = "/Pt", sortable = true }, - { width = 220, label = "Detail", sortable = true }, - }, - findThread = { - { width = 170, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, - { width = 60, label = "Score", sortable = true }, - { width = 70, label = "/Pt", sortable = true }, - { width = 90, label = "Ring", sortable = true }, - { width = 130, label = "Detail", sortable = true }, - }, - } - self.defaultSortByMode = { - computeSocket = 5, - computeSocketAll = 6, - find = 4, - findThread = 4, - } - self.resultTooltip = new("Tooltip"):Tooltip() - self.itemTooltip = new("Tooltip"):Tooltip() - return self -end - ----@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 function clampRectPosition(x, y, width, height) - x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) - y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) - return x, y - end - local function rectsOverlap(aX, aY, aW, aH, bX, bY, bW, bH) - return aX < bX + bW and aX + aW > bX and aY < bY + bH and aY + aH > bY - end - local function placeTooltip(ttW, ttH, cursorX, cursorY, blockedRects) - local candidates = { - { x = cursorX + 20, y = cursorY + 20 }, - { x = cursorX - ttW - 20, y = cursorY + 20 }, - { x = cursorX + 20, y = cursorY - ttH - 20 }, - { x = cursorX - ttW - 20, y = cursorY - ttH - 20 }, - } - for _, candidate in ipairs(candidates) do - local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH) - local overlaps = false - for _, blockedRect in ipairs(blockedRects or { }) do - if rectsOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then - overlaps = true - break - end - end - if not overlaps then - return ttX, ttY - end - end - return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) - end - - local cursorX, cursorY = GetCursorPos() - if hoverLine.item then - SetDrawLayer(nil, 100) - self.itemTooltip:Clear(true) - self.build.itemsTab:AddItemTooltip(self.itemTooltip, hoverLine.item) - local ttW, ttH = self.itemTooltip:GetSize() - local ttX, ttY = placeTooltip(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) - self.nodeTooltip:Clear(true) - local prevShowStatDifferences = self.socketViewer.showStatDifferences - self.socketViewer.showStatDifferences = true - self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build) - self.socketViewer.showStatDifferences = prevShowStatDifferences - local ttW, ttH = self.nodeTooltip:GetSize() - local ttX, ttY = placeTooltip(ttW, ttH, cursorX, cursorY, { viewerRect }) - self.nodeTooltip:Draw(ttX, ttY, nil, nil, viewPort) - SetDrawLayer(nil, 0) -end - -function RadiusJewelResultsListClass:SetMode(mode, list, defaultText) - self.mode = mode or "message" - self.list = list or { } - self.defaultText = defaultText or "" - self.colList = self.columnsByMode[self.mode] or self.columnsByMode.message - self.colLabels = self.mode ~= "message" and #self.list > 0 - local defaultSort = self.defaultSortByMode[self.mode] - if defaultSort and #self.list > 0 then - self:ReSort(defaultSort) - end - if self.mode ~= "message" and #self.list > 0 then - self:SelectIndex(1) - else - self.selIndex = nil - self.selValue = nil - if self.OnSelect then - self:OnSelect(nil, nil) - end - end -end - -function RadiusJewelResultsListClass:GetHoverInfo(hoverColumn, hoverData) - local detailColumn = hoverColumn and RESULT_DETAIL_COLUMN_BY_MODE[self.mode] == hoverColumn - local socketColumn = hoverColumn and RESULT_SOCKET_COLUMN_BY_MODE[self.mode] == hoverColumn - local showViewer = socketColumn or (detailColumn and hoverData and hoverData.detailNodeId) - local showStatTooltip = hoverData and hoverData.baseOutput and hoverData.compareOutput - and hoverColumn and RESULT_STAT_COLUMNS_BY_MODE[self.mode] and RESULT_STAT_COLUMNS_BY_MODE[self.mode][hoverColumn] - local showItemTooltip = hoverData and hoverData.itemTooltipLines - and hoverColumn and RESULT_ITEM_COLUMNS_BY_MODE[self.mode] and RESULT_ITEM_COLUMNS_BY_MODE[self.mode][hoverColumn] - local hoverNodeId = hoverData and hoverData.socketId or nil - if hoverData and hoverData.detailNodeId and detailColumn then - hoverNodeId = hoverData.detailNodeId - end - return { - detailColumn = detailColumn, - socketColumn = socketColumn, - showViewer = showViewer, - showStatTooltip = showStatTooltip, - showItemTooltip = showItemTooltip, - hoverNodeId = hoverNodeId, - } -end - -function RadiusJewelResultsListClass:ReSort(colIndex) - if self.mode == "computeSocket" then - if colIndex == 1 then - t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) - elseif colIndex == 2 then - t_sort(self.list, function(a, b) return a.points < b.points end) - elseif colIndex == 3 then - t_sort(self.list, function(a, b) return a.delta > b.delta end) - elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.pct > b.pct end) - elseif colIndex == 5 then - t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) - elseif colIndex == 6 then - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - elseif self.mode == "computeSocketAll" then - if colIndex == 1 then - t_sort(self.list, function(a, b) return a.jewelName < b.jewelName end) - elseif colIndex == 2 then - t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) - elseif colIndex == 3 then - t_sort(self.list, function(a, b) return a.points < b.points end) - elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.delta > b.delta end) - elseif colIndex == 5 then - t_sort(self.list, function(a, b) return a.pct > b.pct end) - elseif colIndex == 6 then - t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) - elseif colIndex == 7 then - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - elseif self.mode == "find" or self.mode == "findThread" then - if colIndex == 1 then - t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) - elseif colIndex == 2 then - t_sort(self.list, function(a, b) return a.points < b.points end) - elseif colIndex == 3 then - t_sort(self.list, function(a, b) return a.score > b.score end) - elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.scorePerPointSort > b.scorePerPointSort end) - elseif colIndex == 5 then - if self.mode == "findThread" then - t_sort(self.list, function(a, b) return a.variantLabel < b.variantLabel end) - else - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - elseif colIndex == 6 and self.mode == "findThread" then - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - end -end - -function RadiusJewelResultsListClass:GetRowValue(column, index, row) - if self.mode == "message" then - return column == 1 and row.text or "" - elseif self.mode == "computeSocket" then - return column == 1 and colorSocketLabel(row) - or column == 2 and tostring(row.points) - or column == 3 and formatSignedValue(row.delta) - or column == 4 and formatSignedPercent(row.pct) - or column == 5 and formatPerPointDisplay(row.pctPerPoint, row.points) - or column == 6 and row.detailText - or "" - elseif self.mode == "computeSocketAll" then - return column == 1 and row.jewelName - or column == 2 and colorSocketLabel(row) - or column == 3 and tostring(row.points) - or column == 4 and formatSignedValue(row.delta) - or column == 5 and formatSignedPercent(row.pct) - or column == 6 and formatPerPointDisplay(row.pctPerPoint, row.points) - or column == 7 and row.detailText - or "" - elseif self.mode == "find" then - return column == 1 and colorSocketLabel(row) - or column == 2 and tostring(row.points) - or column == 3 and s_format("^7%d", row.score) - or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) - or column == 5 and row.detailText - or "" - elseif self.mode == "findThread" then - return column == 1 and colorSocketLabel(row) - or column == 2 and tostring(row.points) - or column == 3 and s_format("^7%d", row.score) - or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) - or column == 5 and row.variantLabel - or column == 6 and row.detailText - or "" - end - return "" -end - -function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) - self.ListControl.Draw(self, viewPort, true) - if self.suppressTooltipFunc and self.suppressTooltipFunc() then - return - end - local hoverData = self.hoverValue - if not hoverData or main.popups[2] then - return - end - - local function clampRectPosition(x, y, width, height) - x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) - y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) - return x, y - end - local function rectsOverlap(aX, aY, aW, aH, bX, bY, bW, bH) - return aX < bX + bW and aX + aW > bX and aY < bY + bH and aY + aH > bY - end - local function placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRects) - local candidates = { - { x = cursorX + 20, y = cursorY + 20 }, - { x = cursorX - ttW - 20, y = cursorY + 20 }, - { x = cursorX + 20, y = cursorY - ttH - 20 }, - { x = cursorX - ttW - 20, y = cursorY - ttH - 20 }, - } - local primaryBlockedRect = blockedRects and blockedRects[1] or nil - if primaryBlockedRect then - t_insert(candidates, 1, { x = primaryBlockedRect.x - ttW - 12, y = cursorY + 20 }) - t_insert(candidates, 2, { x = primaryBlockedRect.x + primaryBlockedRect.width + 12, y = cursorY + 20 }) - t_insert(candidates, 3, { x = primaryBlockedRect.x, y = primaryBlockedRect.y - ttH - 12 }) - t_insert(candidates, 4, { x = primaryBlockedRect.x, y = primaryBlockedRect.y + primaryBlockedRect.height + 12 }) - end - for _, candidate in ipairs(candidates) do - local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH) - local overlapsBlockedRect = false - for _, blockedRect in ipairs(blockedRects or { }) do - if rectsOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then - overlapsBlockedRect = true - break - end - end - if not overlapsBlockedRect then - return ttX, ttY - end - end - return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) - end - - local cursorX, cursorY = GetCursorPos() - local x, y = self:GetPos() - local relX = cursorX - (x + 2) - local hoverColumn - if hoverData then - for columnIndex, column in ipairs(self.colList) do - local colOffset = column._offset or 0 - local colWidth = column._width or 0 - if relX >= colOffset and relX < colOffset + colWidth then - hoverColumn = columnIndex - break - end - end - end - local hoverInfo = self:GetHoverInfo(hoverColumn, hoverData) - local viewerRect - if hoverInfo.showViewer and hoverInfo.hoverNodeId then - local node = self.build.spec.nodes[hoverInfo.hoverNodeId] or self.build.spec.tree.nodes[hoverInfo.hoverNodeId] - if node then - SetDrawLayer(nil, 15) - local viewerX = cursorX + 20 - local viewerY = cursorY - 150 - if viewerX + 304 > viewPort.x + viewPort.width then viewerX = cursorX - 324 end - if viewerY < viewPort.y then viewerY = viewPort.y elseif viewerY + 304 > viewPort.y + viewPort.height then viewerY = viewPort.y + viewPort.height - 304 end - viewerRect = { x = viewerX, y = viewerY, width = 304, height = 304 } - - SetDrawColor(1, 1, 1) - DrawImage(nil, viewerX, viewerY, 304, 304) - self.socketViewer.zoom = 5 - local scale = self.build.spec.tree.size / 1500 - self.socketViewer.zoomX = -node.x / scale - self.socketViewer.zoomY = -node.y / scale - self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = true - SetViewport(viewerX + 2, viewerY + 2, 300, 300) - self.socketViewer:Draw(self.build, { x = 0, y = 0, width = 300, height = 300 }, { }) - self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = nil - SetDrawLayer(nil, 30) - SetDrawColor(1, 1, 1, 0.2) - DrawImage(nil, 149, 0, 2, 300) - DrawImage(nil, 0, 149, 300, 2) - SetViewport() - SetDrawLayer(nil, 0) - end - end - - local blockedRects = { } - if viewerRect then - t_insert(blockedRects, viewerRect) - end - if hoverInfo.showStatTooltip then - SetDrawLayer(nil, 100) - self.resultTooltip:Clear() - local count = self.build:AddStatComparesToTooltip(self.resultTooltip, hoverData.baseOutput, hoverData.compareOutput, - hoverData.tooltipHeader or "^7Socketing this jewel will give you:") - if count == 0 then - self.resultTooltip:AddLine(14, "^7No stat changes for this result.") - end - local ttW, ttH = self.resultTooltip:GetSize() - local ttX, ttY = placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRects) - self.resultTooltip:Draw(ttX, ttY, nil, nil, viewPort) - t_insert(blockedRects, { x = ttX, y = ttY, width = ttW, height = ttH }) - SetDrawLayer(nil, 0) - end - if hoverInfo.showItemTooltip then - SetDrawLayer(nil, 100) - self.itemTooltip:Clear(true) - for _, line in ipairs(hoverData.itemTooltipLines) do - self.itemTooltip:AddLine(line.height or 16, line[1], line.font) - end - local itemTtW, itemTtH = self.itemTooltip:GetSize() - local itemTtX, itemTtY = placeResultTooltip(itemTtW, itemTtH, cursorX, cursorY, blockedRects) - self.itemTooltip:Draw(itemTtX, itemTtY, nil, nil, viewPort) - SetDrawLayer(nil, 0) - end -end - ---@class RadiusJewelFinder local RadiusJewelFinderClass = newClass("RadiusJewelFinder") diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua new file mode 100644 index 0000000000..66d4ac0a4f --- /dev/null +++ b/src/Classes/RadiusJewelResultsListControl.lua @@ -0,0 +1,380 @@ +-- 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 function formatSignedValue(value) + local sign = value >= 0 and "+" or "" + local col = value > 0 and "^2" or (value < 0 and "^1" or "^8") + return s_format("%s%s%.1f", col, sign, value) +end + +local function formatSignedPercent(value) + local sign = value >= 0 and "+" or "" + local col = value > 0 and "^2" or (value < 0 and "^1" or "^8") + return s_format("%s%s%.1f%%", col, sign, value) +end + +local function formatPerPointDisplay(value, points) + if points == 0 then + return value > 0 and "^2Free" or (value < 0 and "^1Free" or "^8Free") + end + return formatSignedPercent(value) +end + +local ACTION_COLORS = { + new = "^2", + move = "^x33AAFF", + moveReplace = "^xBB88FF", + replace = "^xFFAA33", + keep = "^8", +} +local function colorSocketLabel(row) + return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel +end + +local RESULT_DETAIL_COLUMN_BY_MODE = { + computeSocket = 6, + computeSocketAll = 7, + find = 5, + findThread = 6, +} +local RESULT_SOCKET_COLUMN_BY_MODE = { + computeSocket = 1, + computeSocketAll = 2, + find = 1, + findThread = 1, +} +local RESULT_STAT_COLUMNS_BY_MODE = { + computeSocket = { [3] = true, [4] = true, [5] = true }, + computeSocketAll = { [4] = true, [5] = true, [6] = true }, +} +local RESULT_ITEM_COLUMNS_BY_MODE = { + computeSocket = { [6] = true }, + computeSocketAll = { [7] = true }, + find = { [5] = true }, + findThread = { [6] = true }, +} + +---@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 = { + { width = rect[3] - 22, label = "" }, + }, + computeSocket = { + { width = 170, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 75, label = "Gain", sortable = true }, + { width = 60, label = "%", sortable = true }, + { width = 65, label = "%/Pt", sortable = true }, + { width = 150, label = "Detail", sortable = true }, + }, + computeSocketAll = { + { width = 120, label = "Jewel", sortable = true }, + { width = 130, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 75, label = "Gain", sortable = true }, + { width = 60, label = "%", sortable = true }, + { width = 65, label = "%/Pt", sortable = true }, + { width = 70, label = "Detail", sortable = true }, + }, + find = { + { width = 170, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 60, label = "Score", sortable = true }, + { width = 70, label = "/Pt", sortable = true }, + { width = 220, label = "Detail", sortable = true }, + }, + findThread = { + { width = 170, label = "Socket", sortable = true }, + { width = 40, label = "Pts", sortable = true }, + { width = 60, label = "Score", sortable = true }, + { width = 70, label = "/Pt", sortable = true }, + { width = 90, label = "Ring", sortable = true }, + { width = 130, label = "Detail", sortable = true }, + }, + } + self.defaultSortByMode = { + computeSocket = 5, + computeSocketAll = 6, + find = 4, + findThread = 4, + } + self.resultTooltip = new("Tooltip"):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 detailColumn = hoverColumn and RESULT_DETAIL_COLUMN_BY_MODE[self.mode] == hoverColumn + local socketColumn = hoverColumn and RESULT_SOCKET_COLUMN_BY_MODE[self.mode] == hoverColumn + local showViewer = socketColumn or (detailColumn and hoverData and hoverData.detailNodeId) + local showStatTooltip = hoverData and hoverData.baseOutput and hoverData.compareOutput + and hoverColumn and RESULT_STAT_COLUMNS_BY_MODE[self.mode] and RESULT_STAT_COLUMNS_BY_MODE[self.mode][hoverColumn] + local showItemTooltip = hoverData and hoverData.itemTooltipLines + and hoverColumn and RESULT_ITEM_COLUMNS_BY_MODE[self.mode] and RESULT_ITEM_COLUMNS_BY_MODE[self.mode][hoverColumn] + local hoverNodeId = hoverData and hoverData.socketId or nil + if hoverData and hoverData.detailNodeId and detailColumn then + hoverNodeId = hoverData.detailNodeId + end + return { + detailColumn = detailColumn, + socketColumn = socketColumn, + showViewer = showViewer, + showStatTooltip = showStatTooltip, + showItemTooltip = showItemTooltip, + hoverNodeId = hoverNodeId, + } +end + +function RadiusJewelResultsListClass:ReSort(colIndex) + if self.mode == "computeSocket" then + if colIndex == 1 then + t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) + elseif colIndex == 2 then + t_sort(self.list, function(a, b) return a.points < b.points end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) return a.delta > b.delta end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) return a.pct > b.pct end) + elseif colIndex == 5 then + t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) + elseif colIndex == 6 then + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + elseif self.mode == "computeSocketAll" then + if colIndex == 1 then + t_sort(self.list, function(a, b) return a.jewelName < b.jewelName end) + elseif colIndex == 2 then + t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) return a.points < b.points end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) return a.delta > b.delta end) + elseif colIndex == 5 then + t_sort(self.list, function(a, b) return a.pct > b.pct end) + elseif colIndex == 6 then + t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) + elseif colIndex == 7 then + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + elseif self.mode == "find" or self.mode == "findThread" then + if colIndex == 1 then + t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) + elseif colIndex == 2 then + t_sort(self.list, function(a, b) return a.points < b.points end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) return a.score > b.score end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) return a.scorePerPointSort > b.scorePerPointSort end) + elseif colIndex == 5 then + if self.mode == "findThread" then + t_sort(self.list, function(a, b) return a.variantLabel < b.variantLabel end) + else + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + elseif colIndex == 6 and self.mode == "findThread" then + t_sort(self.list, function(a, b) return a.detailText < b.detailText end) + end + end +end + +function RadiusJewelResultsListClass:GetRowValue(column, index, row) + if self.mode == "message" then + return column == 1 and row.text or "" + elseif self.mode == "computeSocket" then + return column == 1 and colorSocketLabel(row) + or column == 2 and tostring(row.points) + or column == 3 and formatSignedValue(row.delta) + or column == 4 and formatSignedPercent(row.pct) + or column == 5 and formatPerPointDisplay(row.pctPerPoint, row.points) + or column == 6 and row.detailText + or "" + elseif self.mode == "computeSocketAll" then + return column == 1 and row.jewelName + or column == 2 and colorSocketLabel(row) + or column == 3 and tostring(row.points) + or column == 4 and formatSignedValue(row.delta) + or column == 5 and formatSignedPercent(row.pct) + or column == 6 and formatPerPointDisplay(row.pctPerPoint, row.points) + or column == 7 and row.detailText + or "" + elseif self.mode == "find" then + return column == 1 and colorSocketLabel(row) + or column == 2 and tostring(row.points) + or column == 3 and s_format("^7%d", row.score) + or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) + or column == 5 and row.detailText + or "" + elseif self.mode == "findThread" then + return column == 1 and colorSocketLabel(row) + or column == 2 and tostring(row.points) + or column == 3 and s_format("^7%d", row.score) + or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) + or column == 5 and row.variantLabel + or column == 6 and row.detailText + or "" + end + return "" +end + +function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) + self.ListControl.Draw(self, viewPort, true) + if self.suppressTooltipFunc and self.suppressTooltipFunc() then + return + end + local hoverData = self.hoverValue + if not hoverData or main.popups[2] then + return + end + + local function clampRectPosition(x, y, width, height) + x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) + y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) + return x, y + end + local function 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 + local function placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRectangles) + 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 = 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 = clampRectPosition(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 clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) + end + + local cursorX, cursorY = GetCursorPos() + local x, y = self:GetPos() + local relX = cursorX - (x + 2) + local hoverColumn + if hoverData then + for columnIndex, column in ipairs(self.colList) do + local colOffset = column._offset or 0 + local colWidth = column._width or 0 + if relX >= colOffset and relX < colOffset + colWidth then + hoverColumn = columnIndex + break + end + end + end + local hoverInfo = self:GetHoverInfo(hoverColumn, hoverData) + local viewerRect + if hoverInfo.showViewer and hoverInfo.hoverNodeId then + local node = self.build.spec.nodes[hoverInfo.hoverNodeId] or self.build.spec.tree.nodes[hoverInfo.hoverNodeId] + if node then + SetDrawLayer(nil, 15) + local viewerX = cursorX + 20 + local viewerY = cursorY - 150 + if viewerX + 304 > viewPort.x + viewPort.width then viewerX = cursorX - 324 end + if viewerY < viewPort.y then viewerY = viewPort.y elseif viewerY + 304 > viewPort.y + viewPort.height then viewerY = viewPort.y + viewPort.height - 304 end + viewerRect = { x = viewerX, y = viewerY, width = 304, height = 304 } + + SetDrawColor(1, 1, 1) + DrawImage(nil, viewerX, viewerY, 304, 304) + self.socketViewer.zoom = 5 + local scale = self.build.spec.tree.size / 1500 + self.socketViewer.zoomX = -node.x / scale + self.socketViewer.zoomY = -node.y / scale + self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = true + SetViewport(viewerX + 2, viewerY + 2, 300, 300) + self.socketViewer:Draw(self.build, { x = 0, y = 0, width = 300, height = 300 }, { }) + self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = nil + SetDrawLayer(nil, 30) + SetDrawColor(1, 1, 1, 0.2) + DrawImage(nil, 149, 0, 2, 300) + DrawImage(nil, 0, 149, 300, 2) + SetViewport() + SetDrawLayer(nil, 0) + end + end + + local 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 = placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRectangles) + 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 = placeResultTooltip(itemTtW, itemTtH, cursorX, cursorY, blockedRectangles) + self.itemTooltip:Draw(itemTtX, itemTtY, nil, nil, viewPort) + SetDrawLayer(nil, 0) + end +end From 34348e3ce9f158c8bf078e6859cc0d91309b57b7 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 10:47:35 +0200 Subject: [PATCH 21/52] Unify radius jewel result sort keys Use the established sortValue field across compute and finder rows, and call the existing best-per-socket method directly. --- manifest.xml | 4 +-- spec/System/TestRadiusJewelFinder_spec.lua | 6 ++-- src/Classes/RadiusJewelFinder.lua | 28 ++++++++----------- src/Classes/RadiusJewelResultsListControl.lua | 6 ++-- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/manifest.xml b/manifest.xml index a2f2b6c862..89a7926c3a 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,8 +174,8 @@ - - + + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 85ba7cfd00..8cb07ca672 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -430,7 +430,7 @@ describe("RadiusJewelFinder #radius-jewel", function() delta = 10, pct = 10, pctPerPoint = 10, - sortPctPerPoint = 10, + sortValue = 10, detailText = "Test detail", itemTooltipLines = selectedResultPreview, action = "new", @@ -1218,7 +1218,7 @@ describe("RadiusJewelFinder #radius-jewel", function() delta = 0, pct = 0, pctPerPoint = 0, - sortPctPerPoint = 0, + sortValue = 0, detailText = "", action = "replace", replacedItemLabel = "Existing jewel", @@ -1751,7 +1751,7 @@ describe("RadiusJewelFinder #radius-jewel", function() options = options or {} return { socketId = socketId, - sortPctPerPoint = score, + sortValue = score, isSocketIndependent = options.isSocketIndependent, jewelLimitKey = options.jewelLimitKey, jewelLimit = options.jewelLimit, diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index c46e1effac..e9dc4b1713 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -431,7 +431,7 @@ local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCo --- --- Each row is expected to carry: --- socketId (number) – jewel socket id ---- sortPctPerPoint / scorePerPointSort (number) – sort key (higher = better) +--- sortValue (number) – sort key (higher = better) --- isSocketIndependent (boolean?) – true for jewels like IE --- jewelLimitKey (string?) – key for the "Limited to: X" cap --- jewelLimit (number?) – max copies allowed (nil = unlimited) @@ -442,7 +442,7 @@ function RadiusJewelFinderClass:filterBestPerSocket(rows) t_insert(sorted, row) end t_sort(sorted, function(a, b) - return (a.sortPctPerPoint or a.scorePerPointSort or 0) > (b.sortPctPerPoint or b.scorePerPointSort or 0) + return (a.sortValue or 0) > (b.sortValue or 0) end) local usedSockets = { } local limitCounts = { } @@ -469,8 +469,8 @@ function RadiusJewelFinderClass:filterBestPerSocket(rows) end end t_sort(independentSorted, function(a, b) - local aScore = a.sortPctPerPoint or a.scorePerPointSort or 0 - local bScore = b.sortPctPerPoint or b.scorePerPointSort or 0 + local aScore = a.sortValue or 0 + local bScore = b.sortValue or 0 if aScore ~= bScore then return aScore > bScore end @@ -490,7 +490,7 @@ function RadiusJewelFinderClass:filterBestPerSocket(rows) end end t_sort(filtered, function(a, b) - return (a.sortPctPerPoint or a.scorePerPointSort or 0) > (b.sortPctPerPoint or b.scorePerPointSort or 0) + return (a.sortValue or 0) > (b.sortValue or 0) end) return filtered end @@ -602,10 +602,6 @@ function RadiusJewelFinderClass:Open() local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1] local lastComputeAllRows = nil - local function filterBestPerSocket(rows) - return self:filterBestPerSocket(rows) - end - local suppressFinderStateSave = false local runFind local computeContext @@ -676,7 +672,7 @@ function RadiusJewelFinderClass:Open() if cache.mode == "computeSocketAll" then lastComputeAllRows = rows if selectedAllJewelsView.id == "bestPerSocket" then - rows = filterBestPerSocket(rows) + rows = self:filterBestPerSocket(rows) end end controls.resultsList:SetMode(cache.mode, rows, cache.defaultText) @@ -1235,7 +1231,7 @@ end selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[idx] if lastComputeAllRows then local displayRows = selectedAllJewelsView.id == "bestPerSocket" - and filterBestPerSocket(lastComputeAllRows) or lastComputeAllRows + and self:filterBestPerSocket(lastComputeAllRows) or lastComputeAllRows controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)") end saveFinderState() @@ -1588,7 +1584,7 @@ end delta = displayDelta, pct = pct, pctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct, - sortPctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct, + sortValue = totalPoints > 0 and (pct / totalPoints) or pct, detailText = detailText, detailNodeId = detailNodeId, resultNodes = plan.resultNodes, @@ -1704,7 +1700,7 @@ end local bestBySocket = { } for _, row in ipairs(typeRows) do local ex = bestBySocket[row.socketId] - if not ex or row.sortPctPerPoint > ex.sortPctPerPoint then + if not ex or row.sortValue > ex.sortValue then bestBySocket[row.socketId] = row end end @@ -1722,7 +1718,7 @@ end globalBaseline = globalBaseline or 0 lastComputeAllRows = allRows local displayRows = selectedAllJewelsView.id == "bestPerSocket" - and filterBestPerSocket(allRows) or allRows + 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) saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) @@ -2020,7 +2016,7 @@ end local points = isEquippedSocket and 0 or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil }) local scorePerPoint = points > 0 and (r.score / points) or r.score - local scorePerPointSort = points > 0 and scorePerPoint or r.score + local sortValue = points > 0 and scorePerPoint or r.score local detailText = r.detailText if not detailText or detailText == "" then detailText = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel @@ -2050,7 +2046,7 @@ end points = points, score = r.score or 0, scorePerPoint = scorePerPoint, - scorePerPointSort = scorePerPointSort, + sortValue = sortValue, variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.name .. " Ring") or r.variant.dropdownLabel or r.variant.name) or "", detailText = detailText, diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua index 66d4ac0a4f..ba77954f1d 100644 --- a/src/Classes/RadiusJewelResultsListControl.lua +++ b/src/Classes/RadiusJewelResultsListControl.lua @@ -175,7 +175,7 @@ function RadiusJewelResultsListClass:ReSort(colIndex) elseif colIndex == 4 then t_sort(self.list, function(a, b) return a.pct > b.pct end) elseif colIndex == 5 then - t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) + t_sort(self.list, function(a, b) return a.sortValue > b.sortValue end) elseif colIndex == 6 then t_sort(self.list, function(a, b) return a.detailText < b.detailText end) end @@ -191,7 +191,7 @@ function RadiusJewelResultsListClass:ReSort(colIndex) elseif colIndex == 5 then t_sort(self.list, function(a, b) return a.pct > b.pct end) elseif colIndex == 6 then - t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end) + t_sort(self.list, function(a, b) return a.sortValue > b.sortValue end) elseif colIndex == 7 then t_sort(self.list, function(a, b) return a.detailText < b.detailText end) end @@ -203,7 +203,7 @@ function RadiusJewelResultsListClass:ReSort(colIndex) elseif colIndex == 3 then t_sort(self.list, function(a, b) return a.score > b.score end) elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.scorePerPointSort > b.scorePerPointSort end) + t_sort(self.list, function(a, b) return a.sortValue > b.sortValue end) elseif colIndex == 5 then if self.mode == "findThread" then t_sort(self.list, function(a, b) return a.variantLabel < b.variantLabel end) From c6694d7670ac4430cc7d6219047dc00b1d608511 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 11:00:11 +0200 Subject: [PATCH 22/52] Clarify radius jewel module ownership Remove test-only Finder API surface, centralize the full Massive radius boundary, and keep the Finder header tied to the data catalogue owner. --- manifest.xml | 6 ++--- spec/System/TestRadiusJewelFinder_spec.lua | 25 ++++++++++--------- src/Classes/RadiusJewelCompute.lua | 8 ++++-- src/Classes/RadiusJewelData.lua | 3 +++ src/Classes/RadiusJewelFinder.lua | 29 +++++----------------- 5 files changed, 31 insertions(+), 40 deletions(-) diff --git a/manifest.xml b/manifest.xml index 89a7926c3a..23376c33f1 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,10 +171,10 @@ - - + + - + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 8cb07ca672..b99c0a9771 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -648,7 +648,7 @@ describe("RadiusJewelFinder #radius-jewel", function() describe("buildVariantsFromUniqueItem", function() it("builds Light of Meaning variants with valid name and rawText", function() - local variants = makeFinder():buildVariantsFromUniqueItem("The Light of Meaning") + 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) @@ -661,7 +661,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end) it("builds Split Personality variants with unique names", function() - local variants = makeFinder():buildVariantsFromUniqueItem("Split Personality") + 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 @@ -673,7 +673,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end) it("variant rawText contains Selected Variant header", function() - local variants = makeFinder():buildVariantsFromUniqueItem("The Light of Meaning") + 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 @@ -806,7 +806,7 @@ describe("RadiusJewelFinder #radius-jewel", function() it("accepts an injected map fixture and round-trips the mutation", function() local originalModId, newModId = next(data.foulbornMap["Unnatural Instinct"]) - local variants = makeFinder():buildFoulbornVariants("Unnatural Instinct", nil, { + local variants = RadiusJewelData.buildFoulbornVariants("Unnatural Instinct", nil, { ["Unnatural Instinct"] = { [originalModId] = newModId }, }) assert.are.equal(1, #variants) @@ -818,11 +818,11 @@ describe("RadiusJewelFinder #radius-jewel", function() end) it("returns no variants when a unique has no Foulborn mapping", function() - assert.are.equal(0, #makeFinder():buildFoulbornVariants("Anatomical Knowledge")) + assert.are.equal(0, #RadiusJewelData.buildFoulbornVariants("Anatomical Knowledge")) end) it("builds every non-empty Unnatural Instinct mutation subset", function() - local variants = makeFinder():buildFoulbornVariants("Unnatural Instinct") + local variants = RadiusJewelData.buildFoulbornVariants("Unnatural Instinct") assert.are.equal(3, #variants) for _, variant in ipairs(variants) do @@ -868,7 +868,7 @@ describe("RadiusJewelFinder #radius-jewel", function() allocatedNotableD = true, } - for _, variant in ipairs(makeFinder():buildFoulbornVariants("Unnatural Instinct")) do + 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 @@ -882,7 +882,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end) it("uses the mapped Inspired Learning mutation and excludes Foulborn Might of the Meek", function() - local inspired = makeFinder():buildFoulbornVariants("Inspired Learning") + 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({ @@ -895,11 +895,11 @@ describe("RadiusJewelFinder #radius-jewel", function() })) assert.is_not_nil(data.foulbornMap["Might of the Meek"]) - assert.are.equal(0, #makeFinder():buildFoulbornVariants("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 variants = makeFinder():buildFoulbornVariants("Intuitive Leap") + local variants = RadiusJewelData.buildFoulbornVariants("Intuitive Leap") assert.are.equal(1, #variants) local variant = variants[1] assert.is_true(variant.isMassiveRadius) @@ -933,7 +933,8 @@ describe("RadiusJewelFinder #radius-jewel", function() local massiveRadiusIndex for index, radius in ipairs(data.jewelRadius) do - if radius.outer > data.jewelRadius[getSmallRadiusIndex()].outer and radius.outer <= 2400 then + if radius.outer > data.jewelRadius[getSmallRadiusIndex()].outer + and radius.outer <= RadiusJewelData.FULL_MASSIVE_RADIUS then massiveRadiusIndex = index break end @@ -991,7 +992,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end local function getLightOfMeaningVariants() - return makeFinder():buildVariantsFromUniqueItem("The Light of Meaning") + return RadiusJewelData.buildVariantsFromUniqueItem("The Light of Meaning") end it("returns one result per socket and uses the best variant", function() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 18d64c466e..2137ef70db 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -6,7 +6,10 @@ -- -- Usage: -- local attachCompute = LoadModule("Classes/RadiusJewelCompute") --- attachCompute(RadiusJewelFinderClass, { extractTooltipStats, normalizeImpactStat, calculateImpactPercent, mustGetUniqueRawText }) +-- attachCompute(RadiusJewelFinderClass, { +-- extractTooltipStats, normalizeImpactStat, calculateImpactPercent, +-- mustGetUniqueRawText, fullMassiveRadius, +-- }) -- local ipairs = ipairs local pairs = pairs @@ -20,6 +23,7 @@ local extractTooltipStats = helpers.extractTooltipStats local normalizeImpactStat = helpers.normalizeImpactStat local calculateImpactPercent = helpers.calculateImpactPercent local mustGetUniqueRawText = helpers.mustGetUniqueRawText +local FULL_MASSIVE_RADIUS = helpers.fullMassiveRadius -- ───────────────────────────────────────────────────────────────────────────── -- Local helpers @@ -588,7 +592,7 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me return nodes end for idx, radius in ipairs(data.jewelRadius) do - if radius.outer <= 2400 and socketNode.nodesInRadius[idx] then + if radius.outer <= FULL_MASSIVE_RADIUS and socketNode.nodesInRadius[idx] then for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do nodes[nodeId] = node end diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index acc2efcb76..2fa6f5ab4a 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -12,6 +12,9 @@ local s_format = string.format local M = { } +-- Outer boundary for the full Massive radius used by the Foulborn Intuitive Leap effect. +M.FULL_MASSIVE_RADIUS = 2400 + -- ───────────────────────────────────────────────────────────────────────────── -- Color constants -- ───────────────────────────────────────────────────────────────────────────── diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index e9dc4b1713..20ef31da75 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -1,18 +1,8 @@ -- Path of Building -- -- Class: Radius Jewel Finder --- Popup that scores passive tree sockets for radius unique jewels. --- Supports: The Light of Meaning, Might of the Meek, Unnatural Instinct, --- Inspired Learning, Anatomical Knowledge, Thread of Hope, Lioneye's Fall, --- Intuitive Leap, Tempered Flesh, Tempered Mind, Tempered Spirit, --- Transcendent Flesh, Transcendent Mind, Transcendent Spirit, --- Split Personality, Impossible Escape, --- Energy From Within, Healthy Mind, Energised Armour, --- Brute Force Solution, Careful Planning, Efficient Training, --- Fertile Mind, Fluid Motion, Inertia, --- Combat Focus (Crimson/Cobalt/Viridian), --- The Red Dream, The Red Nightmare, The Green Dream, The Green Nightmare, --- The Blue Dream, The Blue Nightmare. +-- Popup for comparing radius unique jewels across passive tree sockets. +-- Supported jewel definitions come from RadiusJewelData.buildJewelTypes(). -- local ipairs = ipairs local pairs = pairs @@ -25,6 +15,7 @@ local m_abs = math.abs local RadiusJewelData = LoadModule("Classes/RadiusJewelData") local COL_META = RadiusJewelData.COL_META +local FULL_MASSIVE_RADIUS = RadiusJewelData.FULL_MASSIVE_RADIUS -- Small output snapshot for stat-comparison tooltips. -- Copies only scalar fields and the small tables needed by @@ -129,15 +120,6 @@ local getSplitPersonalityVariants = RadiusJewelData.getSplitPersonalityVariant local getImpossibleEscapeVariants = RadiusJewelData.getImpossibleEscapeVariants local mustGetUniqueRawText = RadiusJewelData.mustGetUniqueRawText --- Exposed for testing; calls the data module helper. -function RadiusJewelFinderClass:buildVariantsFromUniqueItem(uniqueName, baseName) - return RadiusJewelData.buildVariantsFromUniqueItem(uniqueName, baseName) -end - -function RadiusJewelFinderClass:buildFoulbornVariants(uniqueName, baseName, foulbornMap) - return RadiusJewelData.buildFoulbornVariants(uniqueName, baseName, foulbornMap) -end - -- ───────────────────────────────────────────────────────────────────────────── -- Build jewel socket list -- ───────────────────────────────────────────────────────────────────────────── @@ -420,6 +402,7 @@ local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCo normalizeImpactStat = normalizeImpactStat, calculateImpactPercent = calculateImpactPercent, mustGetUniqueRawText = mustGetUniqueRawText, + fullMassiveRadius = FULL_MASSIVE_RADIUS, }) -- ───────────────────────────────────────────────────────────────────────────── @@ -1948,10 +1931,10 @@ end else local nodes if isMassiveRadiusVariant then - -- Build a temporary full Massive radius (2400). + -- Merge every parsed ring inside the full Massive boundary. nodes = { } for idx, r in ipairs(data.jewelRadius) do - if r.outer <= 2400 and socketNode.nodesInRadius[idx] then + if r.outer <= FULL_MASSIVE_RADIUS and socketNode.nodesInRadius[idx] then for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do nodes[nodeId] = node end From e5c017f40c7fd7266a6a2bb8c8e98bd1e12d1025 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 11:11:05 +0200 Subject: [PATCH 23/52] Share radius jewel presentation helpers Preserve each control's tooltip placement order while centralizing viewport and overlap handling, and reuse one node-label builder across Finder and Compute. --- manifest.xml | 9 ++-- src/Classes/RadiusJewelCompute.lua | 19 +------ src/Classes/RadiusJewelDetailListControl.lua | 37 ++----------- src/Classes/RadiusJewelFinder.lua | 1 + src/Classes/RadiusJewelResultsListControl.lua | 44 ++------------- src/Classes/RadiusJewelTooltipPlacement.lua | 54 +++++++++++++++++++ 6 files changed, 70 insertions(+), 94 deletions(-) create mode 100644 src/Classes/RadiusJewelTooltipPlacement.lua diff --git a/manifest.xml b/manifest.xml index 23376c33f1..cf623fc2a2 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,11 +171,12 @@ - + - - - + + + + diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 2137ef70db..fa97b27ef7 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -8,7 +8,7 @@ -- local attachCompute = LoadModule("Classes/RadiusJewelCompute") -- attachCompute(RadiusJewelFinderClass, { -- extractTooltipStats, normalizeImpactStat, calculateImpactPercent, --- mustGetUniqueRawText, fullMassiveRadius, +-- mustGetUniqueRawText, buildNodeLabelList, fullMassiveRadius, -- }) -- local ipairs = ipairs @@ -23,6 +23,7 @@ local extractTooltipStats = helpers.extractTooltipStats local normalizeImpactStat = helpers.normalizeImpactStat local calculateImpactPercent = helpers.calculateImpactPercent local mustGetUniqueRawText = helpers.mustGetUniqueRawText +local buildNodeLabelList = helpers.buildNodeLabelList local FULL_MASSIVE_RADIUS = helpers.fullMassiveRadius -- ───────────────────────────────────────────────────────────────────────────── @@ -96,22 +97,6 @@ local function copyNodeList(nodes) return out end -local function buildNodeLabelList(nodes) - local labels = { } - for _, node in ipairs(nodes or { }) do - if type(node) == "table" then - if node.label then - t_insert(labels, node.label) - else - t_insert(labels, getPassiveNodeLabel(node)) - end - else - t_insert(labels, tostring(node)) - end - end - return labels -end - local function buildNodeEntries(nodes) local entries = { } for _, node in ipairs(nodes or { }) do diff --git a/src/Classes/RadiusJewelDetailListControl.lua b/src/Classes/RadiusJewelDetailListControl.lua index 7d0356d80e..bb70b4597c 100644 --- a/src/Classes/RadiusJewelDetailListControl.lua +++ b/src/Classes/RadiusJewelDetailListControl.lua @@ -6,6 +6,8 @@ local ipairs = ipairs +local placeTooltip = LoadModule("Classes/RadiusJewelTooltipPlacement").placeTooltip + ---@class RadiusJewelDetailListControl: TextListControl local RadiusJewelDetailListClass = newClass("RadiusJewelDetailListControl", "TextListControl") @@ -45,44 +47,13 @@ function RadiusJewelDetailListClass:Draw(viewPort) return end - local function clampRectPosition(x, y, width, height) - x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) - y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) - return x, y - end - local function 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 - local function placeTooltip(ttW, ttH, cursorX, cursorY, blockedRectangles) - local candidates = { - { x = cursorX + 20, y = cursorY + 20 }, - { x = cursorX - ttW - 20, y = cursorY + 20 }, - { x = cursorX + 20, y = cursorY - ttH - 20 }, - { x = cursorX - ttW - 20, y = cursorY - ttH - 20 }, - } - for _, candidate in ipairs(candidates) do - local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH) - local overlaps = false - for _, blockedRect in ipairs(blockedRectangles or { }) do - if rectanglesOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then - overlaps = true - break - end - end - if not overlaps then - return ttX, ttY - end - end - return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) - end - local cursorX, cursorY = GetCursorPos() if hoverLine.item then SetDrawLayer(nil, 100) self.itemTooltip:Clear(true) self.build.itemsTab:AddItemTooltip(self.itemTooltip, hoverLine.item) local ttW, ttH = self.itemTooltip:GetSize() - local ttX, ttY = placeTooltip(ttW, ttH, cursorX, cursorY) + local ttX, ttY = placeTooltip(viewPort, ttW, ttH, cursorX, cursorY) self.itemTooltip:Draw(ttX, ttY, nil, nil, viewPort) SetDrawLayer(nil, 0) return @@ -125,7 +96,7 @@ function RadiusJewelDetailListClass:Draw(viewPort) self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build) self.socketViewer.showStatDifferences = prevShowStatDifferences local ttW, ttH = self.nodeTooltip:GetSize() - local ttX, ttY = placeTooltip(ttW, ttH, cursorX, cursorY, { viewerRect }) + 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 index 20ef31da75..d255ab313a 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -402,6 +402,7 @@ local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCo normalizeImpactStat = normalizeImpactStat, calculateImpactPercent = calculateImpactPercent, mustGetUniqueRawText = mustGetUniqueRawText, + buildNodeLabelList = buildNodeLabelList, fullMassiveRadius = FULL_MASSIVE_RADIUS, }) diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua index ba77954f1d..235b993f3d 100644 --- a/src/Classes/RadiusJewelResultsListControl.lua +++ b/src/Classes/RadiusJewelResultsListControl.lua @@ -9,6 +9,8 @@ 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") @@ -265,44 +267,6 @@ function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) return end - local function clampRectPosition(x, y, width, height) - x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width)) - y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height)) - return x, y - end - local function 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 - local function placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRectangles) - 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 = 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 = clampRectPosition(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 clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH) - end - local cursorX, cursorY = GetCursorPos() local x, y = self:GetPos() local relX = cursorX - (x + 2) @@ -361,7 +325,7 @@ function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) self.resultTooltip:AddLine(14, "^7No stat changes for this result.") end local ttW, ttH = self.resultTooltip:GetSize() - local ttX, ttY = placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRectangles) + 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) @@ -373,7 +337,7 @@ function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) self.itemTooltip:AddLine(line.height or 16, line[1], line.font) end local itemTtW, itemTtH = self.itemTooltip:GetSize() - local itemTtX, itemTtY = placeResultTooltip(itemTtW, itemTtH, cursorX, cursorY, blockedRectangles) + local itemTtX, itemTtY = placeTooltip(viewPort, itemTtW, itemTtH, cursorX, cursorY, blockedRectangles, true) self.itemTooltip:Draw(itemTtX, itemTtY, nil, nil, viewPort) SetDrawLayer(nil, 0) 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 From a5b57b89583b2be659df8ae247d04866b2cd2560 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 11:20:29 +0200 Subject: [PATCH 24/52] Decompose Impossible Escape evaluation Separate variant preparation, socket grouping, representative evaluation, result fan-out, and plan-detail expansion while preserving cache and progress invariants. --- manifest.xml | 2 +- src/Classes/RadiusJewelCompute.lua | 157 +++++++++++++++++------------ 2 files changed, 93 insertions(+), 66 deletions(-) diff --git a/manifest.xml b/manifest.xml index cf623fc2a2..94a8cc2055 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,7 +171,7 @@ - + diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index fa97b27ef7..6f4dd8ac13 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -875,21 +875,16 @@ function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants return results, realBaseline end -function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) - impactStat = normalizeImpactStat(impactStat) - local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() - local realBaseline = self:getImpactValue(impactStat, baseOutput) - local statField = impactStat.field - local results = { } - local smallRadiusIndex +local function getSmallRadiusIndex() for i, radius in ipairs(data.jewelRadius) do if radius.label == "Small" and radius.inner == 0 then - smallRadiusIndex = i - break + return i end end + return nil +end - local notableOrKeystoneOnly = skipPlanSteps or methodId == "fast" +local function prepareImpossibleEscapeVariants(self, variants, smallRadiusIndex, notableOrKeystoneOnly) local variantDataByName = { } for _, variant in ipairs(variants) do local keystoneNode = self.build.spec.tree.keystoneMap[variant.keystoneName] @@ -912,9 +907,12 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants end end end + return variantDataByName +end - -- Free sockets with the same remaining points share a representative socket; - -- the computed result is copied back onto every socket in the group below. +-- 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 @@ -936,31 +934,35 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants t_insert(groupedEntries[groupKey].sockets, socket) end end - if #groupedOrder == 0 then - return results, realBaseline - end - t_sort(groupedOrder, function(a, b) if a.remainingPoints ~= b.remainingPoints then return a.remainingPoints > b.remainingPoints end return a.representativeSocket.id < b.representativeSocket.id end) - local bestResultByGroupKey = { } - local totalPlanCount = #groupedOrder * #variants - local currentPlanIndex = 0 + return groupedOrder +end - -- Track max candidate count across all variants to detect when remaining points can cover all +local function getMaxCandidateCount(variantDataByName) local maxCandidateCount = 0 for _, variantData in pairs(variantDataByName) do if #variantData.candidates > maxCandidateCount then maxCandidateCount = #variantData.candidates end end + return maxCandidateCount +end + +local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, variants, variantDataByName, methodId, impactStat, statField, calcFunc, planCache, progress) + local bestResultByGroupKey = { } + local totalPlanCount = #groupedOrder * #variants + local currentPlanIndex = 0 + local maxCandidateCount = getMaxCandidateCount(variantDataByName) local previousFreeResult for _, groupEntry in ipairs(groupedOrder) do - -- Skip free groups whose remaining points can cover all candidates: reuse the first free group's result local isFreeGroup = not groupEntry.groupKey:match("^occupied:") + -- Groups are sorted by remaining points. Once they cover every candidate, + -- reuse the first free result; skipped variants still advance progress. if isFreeGroup and previousFreeResult and groupEntry.remainingPoints >= maxCandidateCount then bestResultByGroupKey[groupEntry.groupKey] = previousFreeResult currentPlanIndex = currentPlanIndex + #variants @@ -1033,7 +1035,11 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants end ::continueGroup:: 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 @@ -1047,65 +1053,86 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants 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 - -- Pass 2: compute plan steps for the best variant (single-jewel mode only) - if not skipPlanSteps and methodId == "fast" and #results > 0 then - local topResult = results[1] - local variantData = variantDataByName[topResult.variant.name] - if variantData then - -- Find the group entry for this result to get replacement context - for _, groupEntry in ipairs(groupedOrder) do - local bestResult = bestResultByGroupKey[groupEntry.groupKey] - if bestResult and bestResult.variant.name == topResult.variant.name then - local replacementContext = self:buildSocketReplacementContext(calcFunc, groupEntry.representativeSocket.id) - local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) - local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil - local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, topResult.variant.name, replacementContext) - local fullResult = self:computeDisconnectedPassiveFastPlan( - calcFunc, - replacementContext, - replacementContext.baselineOutput, - socketBaseline, - replacementContext.socketNode, - variantData.item, - impactStat, - variantData.candidates, - topResult.variant.name, - planCache[cacheKey], - nil, - nil, - maxAdditionalNodes, - false, - nil - ) - fullResult.variant = topResult.variant - -- Apply plan steps to all copied results for this variant - for i, r in ipairs(results) do - if r.variant.name == topResult.variant.name then - local updated = copyTableSafe(fullResult, false, true) - updated.socket = r.socket - updated.replacedItemLabel = r.replacedItemLabel - updated.storedUnallocatedItemLabel = r.storedUnallocatedItemLabel - results[i] = updated - end - end - break +local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestResultByGroupKey, variantDataByName, impactStat, statField, calcFunc, planCache) + local topResult = results[1] + local variantData = variantDataByName[topResult.variant.name] + if not variantData then + return + end + for _, groupEntry in ipairs(groupedOrder) do + local bestResult = bestResultByGroupKey[groupEntry.groupKey] + if bestResult and bestResult.variant.name == topResult.variant.name then + local replacementContext = self:buildSocketReplacementContext(calcFunc, groupEntry.representativeSocket.id) + local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) + local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil + local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, topResult.variant.name, replacementContext) + local fullResult = self:computeDisconnectedPassiveFastPlan( + calcFunc, + replacementContext, + replacementContext.baselineOutput, + socketBaseline, + replacementContext.socketNode, + variantData.item, + impactStat, + variantData.candidates, + topResult.variant.name, + planCache[cacheKey], + nil, + nil, + maxAdditionalNodes, + false, + nil + ) + fullResult.variant = topResult.variant + for i, result in ipairs(results) do + if result.variant.name == topResult.variant.name then + local updated = copyTableSafe(fullResult, false, true) + updated.socket = result.socket + updated.replacedItemLabel = result.replacedItemLabel + updated.storedUnallocatedItemLabel = result.storedUnallocatedItemLabel + results[i] = updated end end + break end end +end + +function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + impactStat = normalizeImpactStat(impactStat) + local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() + local realBaseline = self:getImpactValue(impactStat, baseOutput) + local statField = impactStat.field + local 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, variants, variantDataByName, methodId, impactStat, + statField, calcFunc, planCache, progress + ) + local results = fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGroupKey) + if not skipPlanSteps and methodId == "fast" and #results > 0 then + addImpossibleEscapePlanDetails( + self, results, groupedOrder, bestResultByGroupKey, variantDataByName, + impactStat, statField, calcFunc, planCache + ) + end return results, realBaseline end --- Return the helper function for use by the UI return buildDisplayedDisconnectedPassivePlans end -- return function(Class, helpers) From b13435e4c4e82ee27fc505cedbfd0816b189faf5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 11:52:31 +0200 Subject: [PATCH 25/52] Extract radius jewel popup workflows Separate popup setup and Find, Compute, and Apply orchestration so Open remains a small composition boundary. --- manifest.xml | 2 +- src/Classes/RadiusJewelFinder.lua | 1306 ++++++++++++++++------------- 2 files changed, 732 insertions(+), 576 deletions(-) diff --git a/manifest.xml b/manifest.xml index 94a8cc2055..b8a13c5de7 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index d255ab313a..f83dd12f63 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -483,114 +483,671 @@ end -- Open popup -- ───────────────────────────────────────────────────────────────────────────── -function RadiusJewelFinderClass:Open() +local function buildRadiusJewelPopupSetup(self) local treeData = self.build.spec.tree - - -- Radius index map local radiusIndexByLabel = { } - for i, r in ipairs(data.jewelRadius) do - if r.inner == 0 and not radiusIndexByLabel[r.label] then - radiusIndexByLabel[r.label] = i + for i, radius in ipairs(data.jewelRadius) do + if radius.inner == 0 and not radiusIndexByLabel[radius.label] then + radiusIndexByLabel[radius.label] = i end end - -- Thread of Hope ring variants (inner radius > 0) local threadVariants = { } local threadRawText = mustGetUniqueRawText("Thread of Hope") local threadItem = new("Item"):Item("Rarity: Unique\n" .. threadRawText) - local tIdx = 1 - for i, r in ipairs(data.jewelRadius) do - if r.inner > 0 then - local ringName = threadItem.variantList and threadItem.variantList[tIdx] + local threadVariantIndex = 1 + for i, radius in ipairs(data.jewelRadius) do + if radius.inner > 0 then + local ringName = threadItem.variantList and threadItem.variantList[threadVariantIndex] if ringName then ringName = ringName:gsub(" Ring$", "") else - ringName = "Ring " .. tIdx + ringName = "Ring " .. threadVariantIndex end t_insert(threadVariants, { name = ringName, radiusIndex = i }) - tIdx = tIdx + 1 + threadVariantIndex = threadVariantIndex + 1 end end - local LARGE_IDX = radiusIndexByLabel["Large"] - local jewelTypes - local jewelSockets = self:buildJewelSockets(LARGE_IDX) - local ALL_VARIANT_GROUPS_VALUE = "ALL" - - -- Mutable state - local showLegacy = false - local activeJewelTypes = { } -- filtered view of jewelTypes - local selectedJewelType = nil -- set after first filter build - local selectedThreadVariant = threadVariants[1] - local selectedJewelVariant = nil -- set when jewel type has built-in variants - local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1] - local selectedMaxPoints = 20 - local selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[1] - local variantGroupOptions = { { name = "All", value = ALL_VARIANT_GROUPS_VALUE } } - local selectedVariantGroup = variantGroupOptions[1] - - local TL = { "TOPLEFT", nil, "TOPLEFT" } - local BL = { "BOTTOMLEFT", nil, "BOTTOMLEFT" } - local BR = { "BOTTOMRIGHT", nil, "BOTTOMRIGHT" } - local edgePadding = 10 - local buttonHeight = 20 - local leftPanelWidth = 580 - local rightPanelWidth = 410 - local popupWidth = edgePadding * 3 + leftPanelWidth + rightPanelWidth - local popupHeight = 474 - local rightPanelX = edgePadding * 2 + leftPanelWidth - local headerLabelY = 18 - local headerInputY = 34 - local statusLabelY = 62 - local contentTopY = 78 - local resultListBottomY = 430 - local variantDefaultX = 278 - local variantDefaultWidth = 260 - local variantGroupX = variantDefaultX - local variantGroupWidth = 150 - local variantFilteredX = variantGroupX + variantGroupWidth + 8 - local variantFilteredWidth = edgePadding + leftPanelWidth - variantFilteredX - local bottomButtonY = -edgePadding - local bottomInputY = -(edgePadding + 2) - local bottomLabelY = -(edgePadding + 4) - local controls = { } - local applySelectedResult -- set below; used by OnSelClick + applyButton - - -- ── Dropdown label lists ────────────────────────────────────────────────── - -- (jtLabels is built dynamically via rebuildJewelTypeDropdown) - local jtLabels = { } - - local tvLabels = { } - for _, tv in ipairs(threadVariants) do t_insert(tvLabels, tv.name .. " Ring") end - - local socketViewer = new("PassiveTreeView"):PassiveTreeView() - + local threadVariantLabels = { } + for _, variant in ipairs(threadVariants) do + t_insert(threadVariantLabels, variant.name .. " Ring") + end local impactStatLabels = { } - for _, s in ipairs(IMPACT_STATS) do t_insert(impactStatLabels, s.label) end + 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 selectedImpactStat = IMPACT_STATS[1] + for _, option in ipairs(OCCUPIED_SOCKET_OPTIONS) do + t_insert(occupiedModeLabels, option.label) + end + local finderState = self.build.radiusJewelFinderState or { } self.build.radiusJewelFinderState = finderState finderState.findCache = finderState.findCache or { } finderState.computeCache = finderState.computeCache or { } finderState.resultViewByKey = finderState.resultViewByKey or { } finderState.disconnectedPassivePlanCache = finderState.disconnectedPassivePlanCache or { } - local ALL_JEWELS_VIEW_OPTIONS = { - { id = "all", label = "All results" }, + + local allJewelsViewOptions = { + { id = "all", label = "All results" }, { id = "bestPerSocket", label = "Best per socket" }, } - local ALL_VARIANTS_LABEL = "All variants" local allJewelsViewLabels = { } - for _, v in ipairs(ALL_JEWELS_VIEW_OPTIONS) do t_insert(allJewelsViewLabels, v.label) end + 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 runRadiusJewelFind(self, context, makePreferred) + 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 selectedThreadVariant = context.selectedThreadVariant + local selectedJewelVariant = context.selectedJewelVariant + local selectedOccupiedMode = context.selectedOccupiedMode + local getSelectedVariants = context.getSelectedVariants + local formatElapsed = context.formatElapsed + local restoreCachedResults = context.restoreCachedResults + local saveResultCache = context.saveResultCache + local showAllJewelsComputePrompt = context.showAllJewelsComputePrompt + + local searchStartTime = GetTime() + if selectedJewelType and selectedJewelType.isAllJewels then + if not restoreCachedResults() then + showAllJewelsComputePrompt() + end + return + end + controls.statusLabel.label = "^7Searching..." + local ok, err = pcall(function() + local allocNodes = self.build.spec.allocNodes + local isThreadBestVariantSearch = selectedJewelType.isThread == true + local isImpossibleEscapeBestVariantSearch = selectedJewelType.isImpossibleEscape == true + local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true + local isMassiveRadiusVariant = selectedJewelVariant and selectedJewelVariant.isMassiveRadius + local radiusIndex + local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil + if isThreadBestVariantSearch then + if selectedThreadVariant then + radiusIndex = selectedThreadVariant.radiusIndex + end + elseif isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then + radiusIndex = nil + elseif isMassiveRadiusVariant then + -- data.jewelRadius has no full Massive radius; we handle it below. + elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then + radiusIndex = selectedJewelVariant.radiusIndex + else + radiusIndex = selectedJewelType.radiusIndex + end + + if not isThreadBestVariantSearch and not isImpossibleEscapeBestVariantSearch and not isSplitPersonalitySearch + and not radiusIndex and not isMassiveRadiusVariant then + return + end + + local results = { } + local impossibleEscapeBestResult + if isImpossibleEscapeBestVariantSearch then + local variants = getSelectedVariants() or selectedJewelType.variants or { } + for _, variant in ipairs(variants) do + local keystoneNode = treeData.keystoneMap[variant.keystoneName] + local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex and keystoneNode.nodesInRadius[smallRadiusIndex] + if nodes then + local score = selectedJewelType.score(nodes, allocNodes) or 0 + local topNodes = { } + for _, n in pairs(nodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + t_insert(topNodes, { + label = n.dn or n.name or "Unknown", + nodeId = n.id, + }) + end + end + t_sort(topNodes, function(a, b) return a.label < b.label end) + local candidate = { + score = score, + topNodes = topNodes, + variant = variant, + detailText = variant.name, + } + if not impossibleEscapeBestResult + or candidate.score > impossibleEscapeBestResult.score + or (candidate.score == impossibleEscapeBestResult.score and candidate.variant.name < impossibleEscapeBestResult.variant.name) then + impossibleEscapeBestResult = candidate + end + end + end + end + for _, socket in ipairs(jewelSockets) do + local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, selectedOccupiedMode) + local socketNode = treeData.nodes[socket.id] + if socketAllowed and socketNode and (socketNode.nodesInRadius or isSplitPersonalitySearch) then + if isThreadBestVariantSearch then + local bestThreadResult + for _, threadVariant in ipairs(threadVariants) do + local nodes = socketNode.nodesInRadius[threadVariant.radiusIndex] + if nodes then + local score = selectedJewelType.score(nodes, allocNodes) or 0 + local topNodes = { } + for _, n in pairs(nodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + t_insert(topNodes, { + label = n.dn or n.name or "Unknown", + nodeId = n.id, + }) + end + end + t_sort(topNodes, function(a, b) return a.label < b.label end) + local candidate = { + socket = socket, + score = score, + topNodes = topNodes, + variant = threadVariant, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + } + if not bestThreadResult + or candidate.score > bestThreadResult.score + or (candidate.score == bestThreadResult.score and candidate.variant.radiusIndex < bestThreadResult.variant.radiusIndex) then + bestThreadResult = candidate + end + end + end + if bestThreadResult then + t_insert(results, bestThreadResult) + end + elseif isImpossibleEscapeBestVariantSearch and impossibleEscapeBestResult then + t_insert(results, { + socket = socket, + score = impossibleEscapeBestResult.score, + topNodes = impossibleEscapeBestResult.topNodes, + variant = impossibleEscapeBestResult.variant, + detailText = impossibleEscapeBestResult.detailText, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + }) + elseif isSplitPersonalitySearch then + local score = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id) + t_insert(results, { + socket = socket, + score = score, + topNodes = { }, + detailText = s_format("dist to start %d", score), + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + }) + else + local nodes + if isMassiveRadiusVariant then + -- Merge every parsed ring inside the full Massive boundary. + nodes = { } + for idx, r in ipairs(data.jewelRadius) do + if r.outer <= FULL_MASSIVE_RADIUS and socketNode.nodesInRadius[idx] then + for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do + nodes[nodeId] = node + end + end + end + else + nodes = socketNode.nodesInRadius[radiusIndex] + end + + if nodes then + local scoreFn = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.score) + or selectedJewelType.score + local score = scoreFn(nodes, allocNodes) + local detailBuilder = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.detailBuilder) + or selectedJewelType.detailBuilder + local topNodes = { } + for _, n in pairs(nodes) do + if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then + t_insert(topNodes, { + label = n.dn or n.name or "Unknown", + nodeId = n.id, + }) + end + end + t_sort(topNodes, function(a, b) return a.label < b.label end) + t_insert(results, { + socket = socket, + score = score or 0, + topNodes = topNodes, + variant = selectedJewelVariant, + detailText = detailBuilder and detailBuilder(nodes, allocNodes) or nil, + replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, + storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, + }) + end + end + end + end + + t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end) + + local equippedList = self:findEquippedJewelSockets(selectedJewelType) + local equippedSocketIds = { } + local existingSocketId + for _, entry in ipairs(equippedList) do + equippedSocketIds[entry.socketId] = true + if equippedList.atLimit then + existingSocketId = existingSocketId or entry.socketId + end + end + local rows = { } + for _, r in ipairs(results) do + local topLabels = buildNodeLabelList(r.topNodes) + local topStr = t_concat(topLabels, ", ") + if #topStr > 50 then + topStr = topStr:sub(1, 47) .. "..." + end + + local scoreLabel = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.scoreLabel) + or selectedJewelType.scoreLabel + local isEquippedSocket = equippedSocketIds[r.socket.id] + local points = isEquippedSocket and 0 + or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil }) + local scorePerPoint = points > 0 and (r.score / points) or r.score + local sortValue = points > 0 and scorePerPoint or r.score + local detailText = r.detailText + if not detailText or detailText == "" then + detailText = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel + elseif #topStr > 0 and (isThreadBestVariantSearch or isImpossibleEscapeBestVariantSearch) then + detailText = detailText .. s_format(" | %d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") + end + local detailNodeId = nil + if isImpossibleEscapeBestVariantSearch and r.variant and r.variant.keystoneName then + local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] + detailNodeId = keystoneNode and keystoneNode.id or nil + end + local action + if isEquippedSocket then + action = "keep" + elseif existingSocketId and r.replacedItemLabel then + action = "moveReplace" + elseif existingSocketId then + action = "move" + elseif r.replacedItemLabel then + action = "replace" + else + action = "new" + end + t_insert(rows, { + socketLabel = r.socket.label, + socketId = r.socket.id, + points = points, + score = r.score or 0, + scorePerPoint = scorePerPoint, + sortValue = sortValue, + variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.name .. " Ring") + or r.variant.dropdownLabel or r.variant.name) or "", + detailText = detailText, + detailNodeId = detailNodeId, + topNodes = copyTableSafe(r.topNodes, false, true), + replacedItemLabel = r.replacedItemLabel, + storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, + action = action, + applyRawText = (r.variant and r.variant.rawText) + or (selectedJewelVariant and selectedJewelVariant.rawText) + or selectedJewelType.rawText, + }) + end + controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)") + local elapsed = formatElapsed(searchStartTime) + controls.statusLabel.label = (isThreadBestVariantSearch + and s_format("^7Thread of Hope | %d | score/pt", #results) + or isImpossibleEscapeBestVariantSearch + and s_format("^7Impossible Escape | %d | score/pt", #results) + or isSplitPersonalitySearch + and s_format("^7Split Personality | %d | score/pt", #results) + or s_format("^7%d results | score/pt", #results)) .. elapsed + saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred) + if not makePreferred then + restoreCachedResults() + end + end) + if not ok then + controls.statusLabel.label = "^1Error: " .. tostring(err) + controls.resultsList:SetMode("message", { + { text = "^1" .. tostring(err) }, + }, "^1Error") + end +end + +local function applyRadiusJewelResult(self, row) + if not row or not row.applyRawText then + return + end + + local item = new("Item"):Item("Rarity: Unique\n" .. row.applyRawText) + item:BuildModList() + self.build.itemsTab:AddItem(item, true) + + local slot = self.build.itemsTab.sockets[row.socketId] + if slot then + slot:SetSelItemId(item.id) + end + self.build.itemsTab:PopulateSlots() + self.build.buildFlag = true +end + +local function runRadiusJewelCompute(self, context) + local controls = context.controls + local computeState = context.computeState + local cancelCompute = context.cancelCompute + local restoreCachedResults = context.restoreCachedResults + local setComputeProgress = context.setComputeProgress + local makeComputeProgressTracker = context.makeComputeProgressTracker + local selectedImpactStat = context.selectedImpactStat + local selectedComputeMethod = context.selectedComputeMethod + local selectedJewelType = context.selectedJewelType + local selectedJewelSupportsComputeMethods = context.selectedJewelSupportsComputeMethods + local activeJewelTypes = context.activeJewelTypes + local jewelSockets = context.jewelSockets + local threadVariants = context.threadVariants + local finderState = context.finderState + 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 saveResultCache = context.saveResultCache + local getSelectedVariants = context.getSelectedVariants + local hasVariantGroups = context.hasVariantGroups + local selectedVariantGroup = context.selectedVariantGroup + local ALL_VARIANT_GROUPS_VALUE = context.allVariantGroupsValue + + if computeState.computeContext then + cancelCompute("^8Compute stopped") + restoreCachedResults() + return + end + + controls.computeButton.label = "Cancel" + local searchStartTime = GetTime() + setComputeProgress("^7Computing...") + local progress = makeComputeProgressTracker() + computeState.computeContext = { + co = coroutine.create(function() + local ok, err = pcall(function() + local statLabel = selectedImpactStat.label + local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil) + local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil + + if selectedJewelType.isAllJewels then + local allRows = { } + local globalBaseline + + local computeJewelTypes = { } + for _, jt in ipairs(activeJewelTypes) do + if not jt.isAllJewels and jt.hasCompute then + t_insert(computeJewelTypes, jt) + end + end + + for typeIndex, jt in ipairs(computeJewelTypes) do + local rawChild = progress:child( + (typeIndex - 1) / #computeJewelTypes, + 1 / #computeJewelTypes) + local jtName = jt.name + local function wrapProgress(base) + return { + tick = function(self, done, total, label) + base:tick(done, total, label and (jtName .. " | " .. label) or jtName) + end, + child = function(self, startFraction, spanFraction) + return wrapProgress(base:child(startFraction, spanFraction)) + end, + } + end + local typeProgress = wrapProgress(rawChild) + local equippedList = self:findEquippedJewelSockets(jt) + local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } + computeState.computeContext.removedJewels = removedJewels + local socketResults, baseline + + if jt.name == "Intuitive Leap" then + socketResults, baseline = + self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, + computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + elseif jt.isThread then + socketResults, baseline = + self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, + computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + elseif jt.isImpossibleEscape then + socketResults, baseline = + self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, + jt.variants or getImpossibleEscapeVariants(), + computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + elseif jt.isSplitPersonality then + socketResults, baseline = + self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, + jt.variants or getSplitPersonalityVariants(), + typeProgress, selectedMaxPoints, selectedOccupiedMode) + elseif jt.variants and #jt.variants > 0 then + socketResults, baseline = + self:computeBestVariantSocketImpact(jewelSockets, jt.variants, selectedImpactStat, + typeProgress, selectedMaxPoints, selectedOccupiedMode) + else + socketResults, baseline = + self:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, + typeProgress, selectedMaxPoints, selectedOccupiedMode) + end + + globalBaseline = globalBaseline or baseline + self:restoreEquippedJewels(removedJewels) + computeState.computeContext.removedJewels = nil + + local typeRows = buildComputeRows(jt, socketResults, baseline, equippedList) + + -- For disconnected-passive types: keep only the best row per socket + if jt.name == "Intuitive Leap" or jt.isThread or jt.isImpossibleEscape then + local bestBySocket = { } + for _, row in ipairs(typeRows) do + local ex = bestBySocket[row.socketId] + if not ex or row.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 + computeState.lastComputeAllRows = allRows + 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) + saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) + else + local displayedVariants = getSelectedVariants() + local itemLabel = selectedJewelType.name + local equippedList = self:findEquippedJewelSockets(selectedJewelType) + local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } + computeState.computeContext.removedJewels = removedJewels + local socketResults, baseline + if selectedJewelType.name == "Intuitive Leap" then + socketResults, baseline = + self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, + finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isThread then + socketResults, baseline = + self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isImpossibleEscape then + socketResults, baseline = + self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isSplitPersonality then + socketResults, baseline = + self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) + elseif displayedVariants and #displayedVariants > 0 then + if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then + itemLabel = selectedVariantGroup.name + end + socketResults, baseline = + self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + else + local rawText = selectedJewelType.rawText + socketResults, baseline = + self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + end + self:restoreEquippedJewels(removedJewels) + computeState.computeContext.removedJewels = nil + local rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) + controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") + controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) + saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) + end + end) + if not ok then + error(err) + end + end), + } + main.onFrameFuncs["RadiusJewelFinderCompute"] = function() + if not computeState.computeContext then + main.onFrameFuncs["RadiusJewelFinderCompute"] = nil + return + end + local res, errMsg = coroutine.resume(computeState.computeContext.co) + if not res then + cancelCompute() + controls.statusLabel.label = "^1Error: " .. tostring(errMsg) + controls.resultsList:SetMode("message", { + { text = "^1" .. tostring(errMsg) }, + }, "^1Error") + return + end + if coroutine.status(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 = threadVariants[1] + 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 applySelectedResult + 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 lastComputeAllRows = nil + local computeState = { } local suppressFinderStateSave = false local runFind - local computeContext local cancelCompute - local searchStartTime local function formatElapsed(startTime) if not startTime then return "" end @@ -654,7 +1211,7 @@ function RadiusJewelFinderClass:Open() end local rows = copyTableSafe(cache.rows, false, true) if cache.mode == "computeSocketAll" then - lastComputeAllRows = rows + computeState.lastComputeAllRows = rows if selectedAllJewelsView.id == "bestPerSocket" then rows = self:filterBestPerSocket(rows) end @@ -692,14 +1249,14 @@ function RadiusJewelFinderClass:Open() }, message) end cancelCompute = function(statusMessage) - if not computeContext then + if not computeState.computeContext then return end - if computeContext.removedJewels and #computeContext.removedJewels > 0 then - self:restoreEquippedJewels(computeContext.removedJewels) + if computeState.computeContext.removedJewels and #computeState.computeContext.removedJewels > 0 then + self:restoreEquippedJewels(computeState.computeContext.removedJewels) end main.onFrameFuncs["RadiusJewelFinderCompute"] = nil - computeContext = nil + computeState.computeContext = nil if controls.computeButton then controls.computeButton.label = "Compute" end @@ -919,7 +1476,6 @@ end saveFinderState() end - -- ── Preview list (right panel) ──────────────────────────────────────────── local previewListData = { } local resultDetailListData = { } local previewListY = contentTopY @@ -1059,7 +1615,6 @@ end addPreviewLines(lines) end - -- ── Results list (left panel) ───────────────────────────────────────────── controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, contentTopY, leftPanelWidth, resultListBottomY - contentTopY }, self.build, socketViewer) controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped controls.resultsList.OnSelect = function(_, _, row) @@ -1073,7 +1628,6 @@ end end controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") - -- ── Helper: rebuild jewel type dropdown after filter change ────────────── local function rebuildJewelTypeDropdown() jewelTypes = buildJewelTypes() activeJewelTypes = { } @@ -1126,7 +1680,6 @@ end end rebuildJewelTypeDropdown() -- initial build (controls.jewelTypeSelect not yet created) - -- ── Header controls ─────────────────────────────────────────────────────── controls.jewelTypeLabel = new("LabelControl"):LabelControl(TL, { edgePadding, headerLabelY, 0, 16 }, "^7Type:") controls.computeMethodLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, headerLabelY, 0, 16 }, "^7Method:") @@ -1213,9 +1766,9 @@ end 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 lastComputeAllRows then + if computeState.lastComputeAllRows then local displayRows = selectedAllJewelsView.id == "bestPerSocket" - and self:filterBestPerSocket(lastComputeAllRows) or lastComputeAllRows + and self:filterBestPerSocket(computeState.lastComputeAllRows) or computeState.lastComputeAllRows controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)") end saveFinderState() @@ -1430,8 +1983,6 @@ end end syncSelectedJewelTypeControls() - -- Compute button: socket sorting with best variant per socket - -- Results go into the left panel (resultListData); jewel preview is unchanged. local function makeComputeProgressTracker() local tracker local function setFraction(self, fraction, label) @@ -1596,182 +2147,37 @@ end end controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, headerInputY, 72, buttonHeight }, "Compute", function() - if computeContext then - cancelCompute("^8Compute stopped") - restoreCachedResults() - return - end - - controls.computeButton.label = "Cancel" - searchStartTime = GetTime() - setComputeProgress("^7Computing...") - local progress = makeComputeProgressTracker() - computeContext = { - co = coroutine.create(function() - local ok, err = pcall(function() - local statLabel = selectedImpactStat.label - local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil) - local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil - - if selectedJewelType.isAllJewels then - local allRows = { } - local globalBaseline - - local computeJewelTypes = { } - for _, jt in ipairs(activeJewelTypes) do - if not jt.isAllJewels and jt.hasCompute then - t_insert(computeJewelTypes, jt) - end - end - - for typeIndex, jt in ipairs(computeJewelTypes) do - local rawChild = progress:child( - (typeIndex - 1) / #computeJewelTypes, - 1 / #computeJewelTypes) - local jtName = jt.name - local function wrapProgress(base) - return { - tick = function(self, done, total, label) - base:tick(done, total, label and (jtName .. " | " .. label) or jtName) - end, - child = function(self, startFraction, spanFraction) - return wrapProgress(base:child(startFraction, spanFraction)) - end, - } - end - local typeProgress = wrapProgress(rawChild) - local equippedList = self:findEquippedJewelSockets(jt) - local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } - computeContext.removedJewels = removedJewels - local socketResults, baseline - - if jt.name == "Intuitive Leap" then - socketResults, baseline = - self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, - computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) - elseif jt.isThread then - socketResults, baseline = - self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, - computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) - elseif jt.isImpossibleEscape then - socketResults, baseline = - self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, - jt.variants or getImpossibleEscapeVariants(), - computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) - elseif jt.isSplitPersonality then - socketResults, baseline = - self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, - jt.variants or getSplitPersonalityVariants(), - typeProgress, selectedMaxPoints, selectedOccupiedMode) - elseif jt.variants and #jt.variants > 0 then - socketResults, baseline = - self:computeBestVariantSocketImpact(jewelSockets, jt.variants, selectedImpactStat, - typeProgress, selectedMaxPoints, selectedOccupiedMode) - else - socketResults, baseline = - self:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, - typeProgress, selectedMaxPoints, selectedOccupiedMode) - end - - globalBaseline = globalBaseline or baseline - self:restoreEquippedJewels(removedJewels) - computeContext.removedJewels = nil - - local typeRows = buildComputeRows(jt, socketResults, baseline, equippedList) - - -- For disconnected-passive types: keep only the best row per socket - if jt.name == "Intuitive Leap" or jt.isThread or jt.isImpossibleEscape then - local bestBySocket = { } - for _, row in ipairs(typeRows) do - local ex = bestBySocket[row.socketId] - if not ex or row.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 - lastComputeAllRows = allRows - local displayRows = selectedAllJewelsView.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) - saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) - else - local displayedVariants = getSelectedVariants() - local itemLabel = selectedJewelType.name - local equippedList = self:findEquippedJewelSockets(selectedJewelType) - local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } - computeContext.removedJewels = removedJewels - local socketResults, baseline - if selectedJewelType.name == "Intuitive Leap" then - socketResults, baseline = - self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, - finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) - elseif selectedJewelType.isThread then - socketResults, baseline = - self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) - elseif selectedJewelType.isImpossibleEscape then - socketResults, baseline = - self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) - elseif selectedJewelType.isSplitPersonality then - socketResults, baseline = - self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) - elseif displayedVariants and #displayedVariants > 0 then - if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then - itemLabel = selectedVariantGroup.name - end - socketResults, baseline = - self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) - else - local rawText = selectedJewelType.rawText - socketResults, baseline = - self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) - end - self:restoreEquippedJewels(removedJewels) - computeContext.removedJewels = nil - local rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) - controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") - controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) - saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) - end - end) - if not ok then - error(err) - end - end), - } - main.onFrameFuncs["RadiusJewelFinderCompute"] = function() - if not computeContext then - main.onFrameFuncs["RadiusJewelFinderCompute"] = nil - return - end - local res, errMsg = coroutine.resume(computeContext.co) - if not res then - cancelCompute() - controls.statusLabel.label = "^1Error: " .. tostring(errMsg) - controls.resultsList:SetMode("message", { - { text = "^1" .. tostring(errMsg) }, - }, "^1Error") - return - end - if coroutine.status(computeContext.co) == "dead" then - cancelCompute() - end - end + runRadiusJewelCompute(self, { + controls = controls, + computeState = computeState, + cancelCompute = cancelCompute, + restoreCachedResults = restoreCachedResults, + setComputeProgress = setComputeProgress, + makeComputeProgressTracker = makeComputeProgressTracker, + selectedImpactStat = selectedImpactStat, + selectedComputeMethod = selectedComputeMethod, + selectedJewelType = selectedJewelType, + selectedJewelSupportsComputeMethods = selectedJewelSupportsComputeMethods, + activeJewelTypes = activeJewelTypes, + jewelSockets = jewelSockets, + threadVariants = threadVariants, + finderState = finderState, + selectedMaxPoints = selectedMaxPoints, + selectedOccupiedMode = selectedOccupiedMode, + buildComputeRows = buildComputeRows, + getSelectedAllJewelsView = function() return selectedAllJewelsView end, + formatComputeStatus = formatComputeStatus, + formatElapsed = formatElapsed, + saveResultCache = saveResultCache, + getSelectedVariants = getSelectedVariants, + hasVariantGroups = hasVariantGroups, + selectedVariantGroup = selectedVariantGroup, + allVariantGroupsValue = ALL_VARIANT_GROUPS_VALUE, + }) end) controls.computeButton.tooltipFunc = function(tooltip) tooltip:Clear(true) - if computeContext then + if computeState.computeContext then tooltip:AddLine(16, "^7Stop the current compute.") tooltip:AddLine(16, "^8Restores the previous results.") return @@ -1785,7 +2191,6 @@ end end controls.computeButton.shown = true - -- Status label 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" @@ -1801,318 +2206,61 @@ end runFind(false) end) - -- ── Find button ─────────────────────────────────────────────────────────── runFind = function(makePreferred) - searchStartTime = GetTime() - if selectedJewelType and selectedJewelType.isAllJewels then - if not restoreCachedResults() then - showAllJewelsComputePrompt() - end - return - end - controls.statusLabel.label = "^7Searching..." - local ok, err = pcall(function() - local allocNodes = self.build.spec.allocNodes - local isThreadBestVariantSearch = selectedJewelType.isThread == true - local isImpossibleEscapeBestVariantSearch = selectedJewelType.isImpossibleEscape == true - local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true - local isMassiveRadiusVariant = selectedJewelVariant and selectedJewelVariant.isMassiveRadius - local radiusIndex - local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil - if isThreadBestVariantSearch then - if selectedThreadVariant then - radiusIndex = selectedThreadVariant.radiusIndex - end - elseif isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then - radiusIndex = nil - elseif isMassiveRadiusVariant then - -- data.jewelRadius has no full Massive radius; we handle it below. - elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then - radiusIndex = selectedJewelVariant.radiusIndex - else - radiusIndex = selectedJewelType.radiusIndex - end - - if not isThreadBestVariantSearch and not isImpossibleEscapeBestVariantSearch and not isSplitPersonalitySearch - and not radiusIndex and not isMassiveRadiusVariant then - return - end - - local results = { } - local impossibleEscapeBestResult - if isImpossibleEscapeBestVariantSearch then - local variants = getSelectedVariants() or selectedJewelType.variants or { } - for _, variant in ipairs(variants) do - local keystoneNode = treeData.keystoneMap[variant.keystoneName] - local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex and keystoneNode.nodesInRadius[smallRadiusIndex] - if nodes then - local score = selectedJewelType.score(nodes, allocNodes) or 0 - local topNodes = { } - for _, n in pairs(nodes) do - if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then - t_insert(topNodes, { - label = n.dn or n.name or "Unknown", - nodeId = n.id, - }) - end - end - t_sort(topNodes, function(a, b) return a.label < b.label end) - local candidate = { - score = score, - topNodes = topNodes, - variant = variant, - detailText = variant.name, - } - if not impossibleEscapeBestResult - or candidate.score > impossibleEscapeBestResult.score - or (candidate.score == impossibleEscapeBestResult.score and candidate.variant.name < impossibleEscapeBestResult.variant.name) then - impossibleEscapeBestResult = candidate - end - end - end - end - for _, socket in ipairs(jewelSockets) do - local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, selectedOccupiedMode) - local socketNode = treeData.nodes[socket.id] - if socketAllowed and socketNode and (socketNode.nodesInRadius or isSplitPersonalitySearch) then - if isThreadBestVariantSearch then - local bestThreadResult - for _, threadVariant in ipairs(threadVariants) do - local nodes = socketNode.nodesInRadius[threadVariant.radiusIndex] - if nodes then - local score = selectedJewelType.score(nodes, allocNodes) or 0 - local topNodes = { } - for _, n in pairs(nodes) do - if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then - t_insert(topNodes, { - label = n.dn or n.name or "Unknown", - nodeId = n.id, - }) - end - end - t_sort(topNodes, function(a, b) return a.label < b.label end) - local candidate = { - socket = socket, - score = score, - topNodes = topNodes, - variant = threadVariant, - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - } - if not bestThreadResult - or candidate.score > bestThreadResult.score - or (candidate.score == bestThreadResult.score and candidate.variant.radiusIndex < bestThreadResult.variant.radiusIndex) then - bestThreadResult = candidate - end - end - end - if bestThreadResult then - t_insert(results, bestThreadResult) - end - elseif isImpossibleEscapeBestVariantSearch and impossibleEscapeBestResult then - t_insert(results, { - socket = socket, - score = impossibleEscapeBestResult.score, - topNodes = impossibleEscapeBestResult.topNodes, - variant = impossibleEscapeBestResult.variant, - detailText = impossibleEscapeBestResult.detailText, - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - }) - elseif isSplitPersonalitySearch then - local score = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id) - t_insert(results, { - socket = socket, - score = score, - topNodes = { }, - detailText = s_format("dist to start %d", score), - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - }) - else - local nodes - if isMassiveRadiusVariant then - -- Merge every parsed ring inside the full Massive boundary. - nodes = { } - for idx, r in ipairs(data.jewelRadius) do - if r.outer <= FULL_MASSIVE_RADIUS and socketNode.nodesInRadius[idx] then - for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do - nodes[nodeId] = node - end - end - end - else - nodes = socketNode.nodesInRadius[radiusIndex] - end - - if nodes then - local scoreFn = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.score) - or selectedJewelType.score - local score = scoreFn(nodes, allocNodes) - local detailBuilder = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.detailBuilder) - or selectedJewelType.detailBuilder - local topNodes = { } - for _, n in pairs(nodes) do - if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then - t_insert(topNodes, { - label = n.dn or n.name or "Unknown", - nodeId = n.id, - }) - end - end - t_sort(topNodes, function(a, b) return a.label < b.label end) - t_insert(results, { - socket = socket, - score = score or 0, - topNodes = topNodes, - variant = selectedJewelVariant, - detailText = detailBuilder and detailBuilder(nodes, allocNodes) or nil, - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - }) - end - end - end - end - - t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end) - - local equippedList = self:findEquippedJewelSockets(selectedJewelType) - local equippedSocketIds = { } - local existingSocketId - for _, entry in ipairs(equippedList) do - equippedSocketIds[entry.socketId] = true - if equippedList.atLimit then - existingSocketId = existingSocketId or entry.socketId - end - end - local rows = { } - for _, r in ipairs(results) do - local topLabels = buildNodeLabelList(r.topNodes) - local topStr = t_concat(topLabels, ", ") - if #topStr > 50 then - topStr = topStr:sub(1, 47) .. "..." - end + runRadiusJewelFind(self, { + controls = controls, + treeData = treeData, + radiusIndexByLabel = radiusIndexByLabel, + threadVariants = threadVariants, + jewelSockets = jewelSockets, + selectedJewelType = selectedJewelType, + selectedThreadVariant = selectedThreadVariant, + selectedJewelVariant = selectedJewelVariant, + selectedOccupiedMode = selectedOccupiedMode, + getSelectedVariants = getSelectedVariants, + formatElapsed = formatElapsed, + restoreCachedResults = restoreCachedResults, + saveResultCache = saveResultCache, + showAllJewelsComputePrompt = showAllJewelsComputePrompt, + }, makePreferred) + end + controls.findButton = new("ButtonControl"):ButtonControl(BL, { edgePadding, bottomButtonY, 100, buttonHeight }, "Find", function() + cancelCompute() + runFind(true) + end) + controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels) + controls.findButton.tooltipFunc = function(tooltip) + tooltip:Clear(true) + tooltip:AddLine(16, "^7Find sockets with matching passives for this jewel.") + tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.") + end - 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 = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel - elseif #topStr > 0 and (isThreadBestVariantSearch or isImpossibleEscapeBestVariantSearch) then - detailText = detailText .. s_format(" | %d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") - end - local detailNodeId = nil - if isImpossibleEscapeBestVariantSearch and r.variant and r.variant.keystoneName then - local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] - detailNodeId = keystoneNode and keystoneNode.id or nil - end - local action - if isEquippedSocket then - action = "keep" - elseif existingSocketId and r.replacedItemLabel then - action = "moveReplace" - elseif existingSocketId then - action = "move" - elseif r.replacedItemLabel then - action = "replace" - else - action = "new" - end - t_insert(rows, { - socketLabel = r.socket.label, - socketId = r.socket.id, - points = points, - score = r.score or 0, - scorePerPoint = scorePerPoint, - sortValue = sortValue, - variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.name .. " Ring") - or r.variant.dropdownLabel or r.variant.name) or "", - detailText = detailText, - detailNodeId = detailNodeId, - topNodes = copyTableSafe(r.topNodes, false, true), - replacedItemLabel = r.replacedItemLabel, - storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, - action = action, - applyRawText = (r.variant and r.variant.rawText) - or (selectedJewelVariant and selectedJewelVariant.rawText) - or selectedJewelType.rawText, - }) - end - controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)") - local elapsed = formatElapsed(searchStartTime) - controls.statusLabel.label = (isThreadBestVariantSearch - and s_format("^7Thread of Hope | %d | score/pt", #results) - or isImpossibleEscapeBestVariantSearch - and s_format("^7Impossible Escape | %d | score/pt", #results) - or isSplitPersonalitySearch - and s_format("^7Split Personality | %d | score/pt", #results) - or s_format("^7%d results | score/pt", #results)) .. elapsed - saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred) - if not makePreferred then - restoreCachedResults() - end - end) - if not ok then - controls.statusLabel.label = "^1Error: " .. tostring(err) - controls.resultsList:SetMode("message", { - { text = "^1" .. tostring(err) }, - }, "^1Error") - end - end - controls.findButton = new("ButtonControl"):ButtonControl(BL, { edgePadding, bottomButtonY, 100, buttonHeight }, "Find", function() - cancelCompute() - runFind(true) - end) - controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels) - controls.findButton.tooltipFunc = function(tooltip) + applySelectedResult = function() + local idx = controls.resultsList.selIndex + local row = idx and controls.resultsList.list[idx] + applyRadiusJewelResult(self, row) + end + controls.applyButton = new("ButtonControl"):ButtonControl(BL, { edgePadding + 480, bottomButtonY, 80, buttonHeight }, "Apply", applySelectedResult) + controls.applyButton.enabled = function() + local idx = controls.resultsList.selIndex + return idx and controls.resultsList.list[idx] and controls.resultsList.list[idx].applyRawText ~= nil + end + controls.applyButton.tooltipFunc = function(tooltip) + local idx = controls.resultsList.selIndex + local row = idx and controls.resultsList.list[idx] + if not row or not row.applyRawText then tooltip:Clear(true) - tooltip:AddLine(16, "^7Find sockets with matching passives for this jewel.") - tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.") + tooltip:AddLine(16, "^7Select a result to apply.") + return end - - applySelectedResult = function() - local idx = controls.resultsList.selIndex - local row = idx and controls.resultsList.list[idx] - if not row or not row.applyRawText then return end - - local item = new("Item"):Item("Rarity: Unique\n" .. row.applyRawText) - item:BuildModList() - self.build.itemsTab:AddItem(item, true) - - local slot = self.build.itemsTab.sockets[row.socketId] - if slot then - slot:SetSelItemId(item.id) - end - self.build.itemsTab:PopulateSlots() - self.build.buildFlag = true - end - controls.applyButton = new("ButtonControl"):ButtonControl(BL, { edgePadding + 480, bottomButtonY, 80, buttonHeight }, "Apply", applySelectedResult) - controls.applyButton.enabled = function() - local idx = controls.resultsList.selIndex - return idx and controls.resultsList.list[idx] and controls.resultsList.list[idx].applyRawText ~= nil - end - controls.applyButton.tooltipFunc = function(tooltip) - local idx = controls.resultsList.selIndex - local row = idx and controls.resultsList.list[idx] - if not row or not row.applyRawText then - tooltip:Clear(true) - tooltip:AddLine(16, "^7Select a result to apply.") - return - end - tooltip:Clear(true) - tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket")) - tooltip:AddLine(16, "^8Adds the jewel to this build.") - if row.storedUnallocatedItemLabel then - tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.") - end - tooltip:AddLine(16, "^8Double-click a result to apply it.") + tooltip:Clear(true) + tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket")) + tooltip:AddLine(16, "^8Adds the jewel to this build.") + if row.storedUnallocatedItemLabel then + tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.") end + tooltip:AddLine(16, "^8Double-click a result to apply it.") + end local function restoreFinderState() if not finderState.jewelTypeName then @@ -2219,20 +2367,28 @@ end runFind(false) end - -- Close button controls.closeButton = new("ButtonControl"):ButtonControl(BR, { -edgePadding, bottomButtonY, 100, buttonHeight }, "Close", function() cancelCompute() main:ClosePopup() end) - -- Initialise preview and open popup - restoreFinderState() - local popup = main:OpenPopup(popupWidth, popupHeight, "Find Radius Jewel", controls, nil, nil, "closeButton") + 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 - controls.computeButton:Click() + context.controls.computeButton:Click() return end end From 386c02f70fec1a6a9ce98201db0a27f59083ef38 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 12:07:32 +0200 Subject: [PATCH 26/52] Split radius jewel system specs Separate popup, data, and compute coverage while sharing fixture helpers without changing the 93-test Finder set. --- spec/System/RadiusJewelFinderTestSupport.lua | 160 ++ spec/System/TestRadiusJewelCompute_spec.lua | 1325 +++++++++++++ spec/System/TestRadiusJewelData_spec.lua | 356 ++++ spec/System/TestRadiusJewelFinder_spec.lua | 1806 +----------------- 4 files changed, 1846 insertions(+), 1801 deletions(-) create mode 100644 spec/System/RadiusJewelFinderTestSupport.lua create mode 100644 spec/System/TestRadiusJewelCompute_spec.lua create mode 100644 spec/System/TestRadiusJewelData_spec.lua diff --git a/spec/System/RadiusJewelFinderTestSupport.lua b/spec/System/RadiusJewelFinderTestSupport.lua new file mode 100644 index 0000000000..3e4ee68299 --- /dev/null +++ b/spec/System/RadiusJewelFinderTestSupport.lua @@ -0,0 +1,160 @@ +-- 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() + local names = { "Small", "Medium", "Large", "Very Large", "Massive" } + local variants = { } + local variantIndex = 1 + for radiusIndex, radius in ipairs(data.jewelRadius) do + if radius.inner > 0 then + variants[#variants + 1] = { + name = names[variantIndex] or ("Ring " .. variantIndex), + radiusIndex = radiusIndex, + } + variantIndex = variantIndex + 1 + end + end + return variants +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 + for _ in pairs(build.itemsTab.items) do + itemCount = itemCount + 1 + end + + return { + socketSelItemIds = socketSelItemIds, + itemOrderList = itemOrderList, + itemCount = itemCount, + jewels = copyTable(build.spec.jewels, true), + } +end + +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.jewels, after.jewels) +end + +return support diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua new file mode 100644 index 0000000000..7a8774bea0 --- /dev/null +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -0,0 +1,1325 @@ +-- 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():computeBestVariantSocketImpact(sockets, variants, "Life") + assert.is_true(#results > 0, "expected at least one result") + assert.is_true(#results <= #sockets, "should return no more than socket count") + assert.is_number(baseline) + assert.is_true(baseline > 0) + for _, r in ipairs(results) do + assert.is_not_nil(r.socket) + assert.is_not_nil(r.variant) + assert.is_string(r.variant.name) + assert.is_number(r.delta) + end + end) + + it("results are sorted by delta descending", function() + local sockets = getSockets() + local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + assert.is_true(isSorted(results, "delta"), + "results should be sorted by delta descending") + end) + + it("Life variant selected on sockets where it is better than others", function() + local sockets = getSockets() + local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + local hasLife = false + for _, r in ipairs(results) do + if r.variant.name == "Life" then hasLife = true; break end + end + assert.is_true(hasLife, "expected Life variant to be best for at least one socket") + end) + + it("restores TotalLife after compute", function() + local sockets = getSockets() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + local after = build.calcsTab.mainOutput["Life"] + assert.are.equal(before, after) + end) + + it("restores socket and item state after compute", function() + local sockets = getSockets() + local before = snapshotFinderState() + makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + assertFinderStateUnchanged(before) + end) + + it("respects occupiedMode filter", function() + local sockets = getSockets() + local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" }) + assert.is_true(#results > 0, "expected results with occupied mode 'all'") + end) + + end) + + 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():computeBestVariantSocketImpact({ { + id = socketId, + label = "Historic socket", + pathDist = 0, + } }, { { + name = "Candidate", + rawText = MIGHT_OF_MEEK_RAW_TEXT, + } }, "Life", nil, nil, { 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: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:computeIntuitiveLeapSocketImpact( + { testSocket }, "Life", nil, "fast", { }, nil, 0, { id = "all" }, 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():computeSplitPersonalitySocketImpact({ { + id = socketId, + label = "Historic socket", + classStartDist = splitDistance, + pathDist = 0, + } }, "Life", { { + name = "Dexterity", + rawText = buildSplitPersonalityRawText("+5 to Dexterity"), + } }, nil, nil, { 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():computeSplitPersonalitySocketImpact({ { + id = testSocket.id, + label = "Stored Historic socket", + classStartDist = 42, + pathDist = 1, + } }, "Life", { { + name = "Dexterity", + rawText = buildSplitPersonalityRawText("+5 to Dexterity"), + } }, nil, nil, { 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 + + it("returns a table (may be empty if all sockets occupied)", function() + local results, baseline = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.is_table(results) + assert.is_number(baseline) + end) + + it("returns the current main output as baseline for the selected stat", function() + local expectedBaseline = build.calcsTab.mainOutput["Life"] + local _, baseline = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.are.equal(expectedBaseline, baseline) + end) + + it("returns at least one result for the fixture build", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.is_true(#results > 0, "expected at least one empty jewel socket result") + end) + + it("MoM: only tests empty sockets (selItemId == 0)", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + for _, r in ipairs(results) do + local slot = build.itemsTab.sockets[r.socket.id] + assert.are.equal(0, slot.selItemId, + "result socket " .. r.socket.id .. " should be empty after compute") + end + end) + + it("MoM: results sorted by delta descending", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.is_true(isSorted(results, "delta"), + "MoM socket results should be sorted by delta descending") + end) + + it("MoM: restores TotalLife after compute", function() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assert.are.equal(before, build.calcsTab.mainOutput["Life"]) + end) + + it("MoM: restores socket and item state after compute", function() + local before = snapshotFinderState() + makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + assertFinderStateUnchanged(before) + end) + + it("UI: restores TotalLife after compute", function() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "Life") + assert.are.equal(before, build.calcsTab.mainOutput["Life"]) + end) + + it("AK: restores TotalLife after compute", function() + local before = build.calcsTab.mainOutput["Life"] + makeFinder():computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "Life") + assert.are.equal(before, build.calcsTab.mainOutput["Life"]) + end) + + it("respects max total points for standard compute", function() + local maxPoints = 2 + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, maxPoints) + for _, r in ipairs(results) do + assert.is_true((r.socket.pathDist or 0) <= maxPoints, + "socket " .. r.socket.id .. " used too many points") + end + end) + + it("occupied sockets (36634, 61419, 41263) are skipped", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } + for _, r in ipairs(results) do + assert.is_nil(occupiedIds[r.socket.id], + "occupied socket " .. r.socket.id .. " should not appear in results") + end + end) + + it("occupiedMode 'all' includes occupied sockets", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) + local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } + local foundOccupied = false + for _, r in ipairs(results) do + if occupiedIds[r.socket.id] then foundOccupied = true; break end + end + assert.is_true(foundOccupied, + "expected at least one occupied socket in results with mode 'all'") + end) + + it("occupiedMode 'safe' returns at least as many results as 'free'", function() + local sockets = getSockets() + local freeResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") + local safeResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "safe" }) + assert.is_true(#safeResults >= #freeResults, + "safe mode should include at least all free sockets") + end) + + it("occupiedMode 'all' returns more results than 'free' (build has occupied sockets)", function() + local sockets = getSockets() + local freeResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") + local allResults, _ = makeFinder():computeSocketImpact( + sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) + assert.is_true(#allResults > #freeResults, + "all mode should include more sockets than free mode (occupied sockets exist)") + end) + + it("each result has socket, value and delta fields", function() + local results, _ = makeFinder():computeSocketImpact( + getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + local seenSocketIds = {} + for _, r in ipairs(results) do + assert.is_not_nil(r.socket) + assert.is_number(r.socket.id) + assert.is_number(r.value) + assert.is_number(r.delta) + assert.is_nil(seenSocketIds[r.socket.id], + "duplicate socket result for socket " .. r.socket.id) + seenSocketIds[r.socket.id] = true + end + end) + + end) + + describe("disconnected passive max total points", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + it("respects max total points for Intuitive Leap", function() + local maxPoints = 4 + local results, _ = makeFinder():computeIntuitiveLeapSocketImpact( + getSockets(), "Life", false, "simulated_greedy", { }, nil, maxPoints) + for _, r in ipairs(results) do + local totalPoints = (r.socket.pathDist or 0) + (r.addedNodeCount or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. r.socket.id .. " plan used too many points") + end + end) + + it("stops at jewel-only when the socket already uses all max points", function() + local targetSocket + for _, socket in ipairs(getSockets()) do + if socket.pathDist and socket.pathDist > 0 then + targetSocket = socket + break + end + end + assert.is_not_nil(targetSocket, "expected at least one socket with path points") + local maxPoints = targetSocket.pathDist + local sockets = { targetSocket } + local fastResults = makeFinder():computeIntuitiveLeapSocketImpact( + sockets, "Life", false, "fast", { }, nil, maxPoints) + local simulatedResults = makeFinder():computeIntuitiveLeapSocketImpact( + sockets, "Life", false, "simulated_greedy", { }, nil, maxPoints) + assert.are.equal(0, fastResults[1].addedNodeCount or 0) + assert.are.equal(0, simulatedResults[1].addedNodeCount or 0) + end) + + end) + + describe("computeSplitPersonalitySocketImpact", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + local variants = { + { name = "Life", rawText = buildSplitPersonalityRawText("+5 to maximum Life") }, + { name = "Mana", rawText = buildSplitPersonalityRawText("+5 to maximum Mana") }, + } + + it("returns results and restores socket distance state", function() + local sockets = getSockets() + local before = snapshotFinderState() + local previousDistanceBySocketId = {} + for _, socket in ipairs(sockets) do + previousDistanceBySocketId[socket.id] = build.spec.nodes[socket.id] and build.spec.nodes[socket.id].distanceToClassStart + end + + local results, baseline = makeFinder():computeSplitPersonalitySocketImpact(sockets, "Life", variants) + + assert.is_true(#results > 0, "expected split personality results") + assert.is_number(baseline) + for _, result in ipairs(results) do + assert.is_not_nil(result.variant) + assert.is_number(result.splitDistance) + assert.is_string(result.detailText) + end + for _, socket in ipairs(sockets) do + local node = build.spec.nodes[socket.id] + assert.are.equal(previousDistanceBySocketId[socket.id], node and node.distanceToClassStart) + end + assertFinderStateUnchanged(before) + end) + + it("respects max total points", function() + local maxPoints = 4 + local results, _ = makeFinder():computeSplitPersonalitySocketImpact( + getSockets(), "Life", variants, nil, maxPoints) + for _, result in ipairs(results) do + local totalPoints = (result.socket.pathDist or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. result.socket.id .. " plan used too many points") + end + end) + + end) + + describe("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():computeBestVariantSocketImpact({ { + id = socketId, + label = "Cluster socket", + pathDist = 0, + } }, { { + name = "Candidate", + rawText = MIGHT_OF_MEEK_RAW_TEXT, + } }, "Life", nil, nil, { 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 + + it("shares fast cache keys except for structural jewel replacements", function() + local finder = makeFinder() + local sharedKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + socketNode = { id = 36634 }, + occupancy = { isOccupied = false }, + }) + local structuralItem = { + type = "Jewel", + jewelData = { conqueredBy = true }, + } + local firstStructuralKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + socketNode = { id = 36634 }, + occupancy = { isOccupied = true, item = structuralItem }, + }) + local secondStructuralKey = finder: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.collectDisconnectedPassiveCandidates + local originalBuildOverride = finder.buildSocketReplacementOverride + local originalCacheKey = finder.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.collectDisconnectedPassiveCandidates = function() + return { + { id = -101, name = "First" }, + { id = -102, name = "Second" }, + { id = -103, name = "Third" }, + } + end + finder.buildSocketReplacementOverride = function(_, _, _, addNodes) + return { addNodes = addNodes } + end + + local function countCalculations(cacheKeyFunc) + finder.getImpossibleEscapePlanCacheKey = cacheKeyFunc + calculationCount = 0 + finder:computeImpossibleEscapeSocketImpact(sockets, "Life", { variant }, "fast", { }, nil, 2, nil, 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.collectDisconnectedPassiveCandidates = originalCollectCandidates + finder.buildSocketReplacementOverride = originalBuildOverride + finder.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 = makeFinder():computeImpossibleEscapeSocketImpact( + sockets, "Life", { variant }, "fast", { }, nil) + local simulatedResults, simulatedBaseline = makeFinder():computeImpossibleEscapeSocketImpact( + sockets, "Life", { variant }, "simulated_greedy", { }, nil) + + assert.is_true(#fastResults > 0, "expected fast Impossible Escape results") + assert.is_true(#simulatedResults > 0, "expected simulated Impossible Escape results") + assert.is_number(fastBaseline) + assert.are.equal(fastBaseline, simulatedBaseline) + assert.are.equal(variant.name, fastResults[1].variant.name) + assert.are.equal(variant.name, simulatedResults[1].variant.name) + assertFinderStateUnchanged(before) + end) + + it("respects max total points", function() + local variant = makeImpossibleEscapeTestVariant() + assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant") + local maxPoints = 4 + local results, _ = makeFinder():computeImpossibleEscapeSocketImpact( + getSockets(), "Life", { variant }, "simulated_greedy", { }, nil, maxPoints) + for _, result in ipairs(results) do + local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. result.socket.id .. " plan used too many points") + end + end) + + end) + + describe("computeThreadOfHopeSocketImpact", function() + + local function getSockets() + return makeFinder():buildJewelSockets(getLargeRadiusIndex()) + end + + local function getTestVariants() + local threadVariants = makeThreadVariants() + return { threadVariants[1], threadVariants[2] or threadVariants[1] } + end + + local function getTestSockets(threadVariants) + for _, socket in ipairs(getSockets()) do + local slot = build.itemsTab.sockets[socket.id] + local node = build.spec.tree.nodes[socket.id] + if slot and slot.selItemId == 0 and node and node.nodesInRadius then + for _, variant in ipairs(threadVariants) do + local radiusNodes = node.nodesInRadius[variant.radiusIndex] + if radiusNodes and next(radiusNodes) then + return { socket } + end + end + end + end + return { getSockets()[1] } + end + + it("returns results for both methods without changing finder state", function() + local threadVariants = getTestVariants() + assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") + local sockets = getTestSockets(threadVariants) + local before = snapshotFinderState() + + local fastResults, fastBaseline = makeFinder():computeThreadOfHopeSocketImpact( + sockets, "Life", threadVariants, "fast", { }, nil) + local simulatedResults, simulatedBaseline = makeFinder():computeThreadOfHopeSocketImpact( + sockets, "Life", threadVariants, "simulated_greedy", { }, nil) + + assert.is_true(#fastResults > 0, "expected fast Thread of Hope results") + assert.is_true(#simulatedResults > 0, "expected simulated Thread of Hope results") + assert.is_number(fastBaseline) + assert.are.equal(fastBaseline, simulatedBaseline) + assert.is_not_nil(fastResults[1].variant) + assert.is_not_nil(simulatedResults[1].variant) + assert.is_number(fastResults[1].variant.radiusIndex) + assert.is_number(simulatedResults[1].variant.radiusIndex) + assert.is_string(fastResults[1].detailText) + assert.is_string(simulatedResults[1].detailText) + assertFinderStateUnchanged(before) + end) + + it("respects max total points", function() + local threadVariants = getTestVariants() + assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") + local maxPoints = 4 + local results, _ = makeFinder():computeThreadOfHopeSocketImpact( + getTestSockets(threadVariants), "Life", threadVariants, "simulated_greedy", { }, nil, maxPoints) + for _, result in ipairs(results) do + local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) + assert.is_true(totalPoints <= maxPoints, + "socket " .. result.socket.id .. " plan used too many points") + end + end) + + end) + + -- ── Jewel limit parsing ───────────────────────────────────────────────── + + describe("jewel limit parsing from raw text", function() + + it("parses Limited to: 1 from Impossible Escape raw text", function() + local rawText = buildImpossibleEscapeRawText("Acrobatics") + local limitKey = rawText:match("^([^\n]+)") + local limit = tonumber(rawText:match("Limited to: (%d+)")) + assert.are.equals("Impossible Escape", limitKey) + assert.are.equals(1, limit) + end) + + it("parses Limited to: 1 from Unnatural Instinct raw text", function() + local limitKey = UNNATURAL_INSTINCT_RAW_TEXT:match("^([^\n]+)") + local limit = tonumber(UNNATURAL_INSTINCT_RAW_TEXT:match("Limited to: (%d+)")) + assert.are.equals("Unnatural Instinct", limitKey) + assert.are.equals(1, limit) + end) + + it("returns nil limit for jewels without Limited to", function() + local limit = tonumber(MIGHT_OF_MEEK_RAW_TEXT:match("Limited to: (%d+)")) + assert.is_nil(limit) + end) + + end) + + -- ── filterBestPerSocket ──────────────────────────────────────────────── + + describe("filterBestPerSocket", function() + + local function makeRow(socketId, score, options) + options = options or {} + return { + socketId = socketId, + sortValue = score, + isSocketIndependent = options.isSocketIndependent, + jewelLimitKey = options.jewelLimitKey, + jewelLimit = options.jewelLimit, + points = options.points, + name = options.name or ("row-" .. socketId), + } + end + + it("keeps one result per socket, highest score is kept", function() + local rows = { + makeRow(1, 10, { name = "A" }), + makeRow(1, 20, { name = "B" }), + makeRow(2, 15, { name = "C" }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local ids = {} + for _, r in ipairs(result) do ids[r.socketId] = r.name end + assert.are.equal("B", ids[1]) + assert.are.equal("C", ids[2]) + end) + + it("results are sorted by score descending", function() + local rows = { + makeRow(1, 5), + makeRow(2, 30), + makeRow(3, 15), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(3, #result) + assert.are.equal(2, result[1].socketId) + assert.are.equal(3, result[2].socketId) + assert.are.equal(1, result[3].socketId) + end) + + it("applies jewelLimit per jewelLimitKey", function() + local rows = { + makeRow(1, 30, { jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(2, 20, { jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(3, 10), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local ids = {} + for _, r in ipairs(result) do ids[r.socketId] = true end + assert.is_true(ids[1], "best IE should be kept") + assert.is_true(ids[3], "unlimited jewel should be kept") + assert.is_nil(ids[2], "second IE should be dropped (limit 1)") + end) + + it("allows multiple copies up to the limit", function() + local rows = { + makeRow(1, 30, { jewelLimitKey = "CF", jewelLimit = 2 }), + makeRow(2, 20, { jewelLimitKey = "CF", jewelLimit = 2 }), + makeRow(3, 10, { jewelLimitKey = "CF", jewelLimit = 2 }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + assert.are.equal(1, result[1].socketId) + assert.are.equal(2, result[2].socketId) + end) + + it("socket-dependent jewels are assigned before socket-independent", function() + -- Socket 1: dependent score 10, independent score 20 + -- The dependent should get socket 1, independent goes to socket 2 + local rows = { + makeRow(1, 10, { name = "dependent" }), + makeRow(1, 20, { name = "independent", isSocketIndependent = true }), + makeRow(2, 5, { name = "independent2", isSocketIndependent = true }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local bySocket = {} + for _, r in ipairs(result) do bySocket[r.socketId] = r.name end + -- The independent with score 20 cannot take socket 1 (dependent uses it) + -- It should go to socket 2 instead + assert.are.equal("dependent", bySocket[1]) + end) + + it("socket-independent jewels use remaining sockets after dependent allocation", function() + local rows = { + makeRow(1, 30, { name = "dependent-1" }), + makeRow(2, 25, { name = "dependent-2" }), + makeRow(1, 20, { name = "independent-1", isSocketIndependent = true }), + makeRow(2, 15, { name = "independent-2", isSocketIndependent = true }), + makeRow(3, 10, { name = "independent-3", isSocketIndependent = true }), + } + local result = makeFinder():filterBestPerSocket(rows) + local bySocket = {} + for _, r in ipairs(result) do bySocket[r.socketId] = r.name end + assert.are.equal("dependent-1", bySocket[1]) + assert.are.equal("dependent-2", bySocket[2]) + assert.are.equal("independent-3", bySocket[3]) + end) + + it("socket-independent tie-break uses fewer points", function() + local rows = { + makeRow(1, 20, { isSocketIndependent = true, points = 5 }), + makeRow(2, 20, { isSocketIndependent = true, points = 2 }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + -- Both are kept (different sockets), but fewer points should come first at equal score + -- Actually both have different sockets so both are included + -- The tie-break matters when multiple rows can use the same remaining sockets + end) + + it("socket-independent tie-break: at equal score, fewer points is kept", function() + -- Two independent jewels can use a single remaining socket + local rows = { + makeRow(1, 50, { name = "dependent" }), -- takes socket 1 + makeRow(1, 20, { name = "ie-high-points", isSocketIndependent = true, points = 8 }), + makeRow(2, 20, { name = "ie-low-points", isSocketIndependent = true, points = 2 }), + } + local result = makeFinder():filterBestPerSocket(rows) + local bySocket = {} + for _, r in ipairs(result) do bySocket[r.socketId] = r.name end + assert.are.equal("dependent", bySocket[1]) + assert.are.equal("ie-low-points", bySocket[2]) + end) + + it("limits are shared between dependent and independent jewels", function() + -- IE limited to 1: if a dependent row with same limitKey is placed first, + -- independent rows with that key are blocked + local rows = { + makeRow(1, 30, { name = "dependent-ie", jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(2, 20, { name = "independent-ie", isSocketIndependent = true, jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(3, 10, { name = "other" }), + } + local result = makeFinder():filterBestPerSocket(rows) + assert.are.equal(2, #result) + local names = {} + for _, r in ipairs(result) do names[r.name] = true end + assert.is_true(names["dependent-ie"]) + assert.is_true(names["other"]) + assert.is_nil(names["independent-ie"], "second IE should be blocked by shared limit") + end) + + it("returns empty table for empty input", function() + local result = makeFinder():filterBestPerSocket({}) + assert.are.equal(0, #result) + end) + + it("does not change the input rows table", function() + local rows = { + makeRow(2, 10), + makeRow(1, 20), + } + local originalLen = #rows + local originalFirst = rows[1] + makeFinder():filterBestPerSocket(rows) + assert.are.equal(originalLen, #rows) + assert.are.equal(originalFirst, rows[1]) + end) + + end) + + -- ── Move-aware compute helpers ───────────────────────────────────────── + + describe("move-aware compute helpers", function() + + local ALLOC_SOCKET_IDS = { 36634, 61419, 41263 } + + local function findUnallocatedSocketId() + for socketId, socketData in pairs(build.spec.nodes) do + if socketData.isJewelSocket and socketData.name ~= "Charm Socket" + and build.itemsTab.sockets[socketId] and not build.spec.allocNodes[socketId] then + return socketId + end + end + error("expected at least one unallocated jewel socket") + end + + local function equipFakeJewel(socketId, title, limit, extraItemFields) + local slot = build.itemsTab.sockets[socketId] + assert.is_not_nil(slot, "socket " .. socketId .. " should exist") + local fakeItemId = 999000 + socketId + local item = { title = title, limit = limit } + if extraItemFields then + for k, v in pairs(extraItemFields) do item[k] = v end + end + build.itemsTab.items[fakeItemId] = item + slot.selItemId = fakeItemId + build.spec.jewels[socketId] = fakeItemId + return item, fakeItemId + end + + local function getTestRadiusIndex() + return getLargeRadiusIndex() + end + + -- Find a jewel socket whose radius contains at least one unallocated node + -- with NO allocated linked nodes outside the radius ("isolated"). + -- Note: `linked` is on spec.nodes, not spec.tree.nodes. + local function findIsolatedRadiusNode(radiusIndex) + local treeData = build.spec.tree + for socketId, socketData in pairs(build.spec.nodes) do + if socketData.isJewelSocket then + local socketNode = treeData.nodes[socketId] + if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then + local radiusNodes = socketNode.nodesInRadius[radiusIndex] + for nodeId, _ in pairs(radiusNodes) do + if not build.spec.allocNodes[nodeId] then + local specNode = build.spec.nodes[nodeId] + local isolated = true + if specNode and specNode.linked then + for _, other in ipairs(specNode.linked) do + if build.spec.allocNodes[other.id] and not radiusNodes[other.id] then + isolated = false + break + end + end + end + if isolated then + return socketId, nodeId + end + end + end + end + end + end + end + + -- Find an unallocated radius node that has at least one linked node + -- OUTSIDE the radius. Returns socketId, nodeId, outsideLinkedNodeId. + -- Note: `linked` is on spec.nodes, not spec.tree.nodes. + local function findRadiusNodeWithOutsideLinkedNode(radiusIndex) + local treeData = build.spec.tree + for socketId, socketData in pairs(build.spec.nodes) do + if socketData.isJewelSocket then + local socketNode = treeData.nodes[socketId] + if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then + local radiusNodes = socketNode.nodesInRadius[radiusIndex] + for nodeId, _ in pairs(radiusNodes) do + if not build.spec.allocNodes[nodeId] then + local specNode = build.spec.nodes[nodeId] + if specNode and specNode.linked then + for _, other in ipairs(specNode.linked) do + if not radiusNodes[other.id] then + return socketId, nodeId, other.id + end + end + end + end + end + end + end + end + end + + -- ── findEquippedJewelSockets ──────────────────────────────────── + + describe("findEquippedJewelSockets", function() + + it("returns empty when no jewel of that type is equipped", function() + local result = makeFinder():findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(0, #result) + end) + + it("ignores jewels stored in unallocated sockets", function() + local socketId = findUnallocatedSocketId() + equipFakeJewel(socketId, "Thread of Hope", 1) + local finder = makeFinder() + local occupancy = finder:getSocketOccupancyInfo(socketId) + local allowed = finder:socketMatchesOccupiedMode(socketId, { id = "free" }) + + assert.is_false(occupancy.isOccupied) + assert.are.equal("Thread of Hope", occupancy.storedUnallocatedItemLabel) + assert.is_true(allowed) + assert.are.equal(7, finder:getSocketBasePoints({ id = socketId, pathDist = 7 }, occupancy)) + + local result = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(0, #result) + assert.is_false(result.atLimit) + end) + + it("returns entry but atLimit=false when equipped jewel has no limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Might of the Meek", nil) + local result = makeFinder():findEquippedJewelSockets({ name = "Might of the Meek" }) + assert.are.equal(1, #result) + assert.are.equal(ALLOC_SOCKET_IDS[1], result[1].socketId) + assert.is_false(result.atLimit) + end) + + it("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: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("returns entry but atLimit=false when equipped count is below limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2) + local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" }) + assert.are.equal(1, #result, "1 equipped < limit 2") + assert.is_false(result.atLimit) + end) + + it("returns all entries with atLimit=true when count equals limit", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2) + equipFakeJewel(ALLOC_SOCKET_IDS[2], "Combat Focus", 2) + local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" }) + assert.are.equal(2, #result) + assert.is_true(result.atLimit) + end) + + it("does not match jewels with different title", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) + local result = makeFinder():findEquippedJewelSockets({ name = "Impossible Escape" }) + assert.are.equal(0, #result) + end) + + end) + + it("computeSocketImpact treats jewels stored in unallocated sockets as free sockets", function() + local socketId = findUnallocatedSocketId() + equipFakeJewel(socketId, "Unnatural Instinct", 1) + local finder = makeFinder() + local results = finder:computeSocketImpact({ + { id = socketId, label = "Test socket", pathDist = 7 }, + }, MIGHT_OF_MEEK_RAW_TEXT, "Life", nil, nil, { id = "free" }) + + assert.are.equal(1, #results) + assert.is_nil(results[1].replacedItemLabel) + assert.are.equal("Unnatural Instinct", results[1].storedUnallocatedItemLabel) + end) + + -- ── findDisconnectedPassiveDependentNodes ───────────────────────────── + + describe("findDisconnectedPassiveDependentNodes", function() + + it("returns empty for items without disconnected passive properties", function() + local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], { title = "Might of the Meek" }) + assert.are.equal(0, #result) + end) + + it("returns empty for invalid socketId", function() + local item = { jewelRadiusIndex = getTestRadiusIndex() } + local result = makeFinder():findDisconnectedPassiveDependentNodes(999999, item) + assert.are.equal(0, #result) + end) + + it("returns empty when no nodes are allocated in radius", function() + local treeData = build.spec.tree + local smallRI = getTestRadiusIndex() + local testSocketId + for socketId, _ in pairs(build.itemsTab.sockets) do + local node = treeData.nodes[socketId] + if node and node.nodesInRadius and node.nodesInRadius[smallRI] + and next(node.nodesInRadius[smallRI]) then + local hasAllocated = false + for nodeId, _ in pairs(node.nodesInRadius[smallRI]) do + if build.spec.allocNodes[nodeId] then + hasAllocated = true + break + end + end + if not hasAllocated then + testSocketId = socketId + break + end + end + end + if not testSocketId then pending("no empty radius socket found") end + local item = { jewelRadiusIndex = smallRI } + local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) + assert.are.equal(0, #result) + end) + + it("returns isolated allocated nodes in radius as dependent", function() + local smallRI = getTestRadiusIndex() + local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI) + if not testSocketId then pending("no isolated radius node found") end + + build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId] + + local item = { jewelRadiusIndex = smallRI } + local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) + + assert.is_true(#result > 0, "expected at least one dependent node") + local found = false + for _, nodeId in ipairs(result) do + if nodeId == testNodeId then found = true; break end + end + assert.is_true(found, "expected node " .. testNodeId .. " in dependent nodes") + end) + + it("excludes nodes connected from outside the radius", function() + local treeData = build.spec.tree + local ri = getTestRadiusIndex() + local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri) + if not testSocketId then pending("no radius node with outside linked node found") end + + -- Allocate both the radius node and its outside linked node + build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId] + build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId] + + local item = { jewelRadiusIndex = ri } + local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) + + local found = false + for _, nodeId in ipairs(result) do + if nodeId == testNodeId then found = true; break end + end + assert.is_false(found, "node connected from outside radius should not be dependent") + end) + + it("handles IE keystoneMap path", function() + local variant = makeImpossibleEscapeTestVariant() + if not variant then pending("no IE keystone variant found") end + + local item = { + jewelData = { impossibleEscapeKeystones = { [variant.keystoneName] = true } }, + } + -- Should return empty since no extra nodes are allocated in the keystone radius + local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], item) + assert.is_table(result) + end) + + end) + + -- ── removeEquippedJewels / restoreEquippedJewels ──────────────── + + describe("removeEquippedJewels / restoreEquippedJewels", function() + + it("remove+restore keeps state identical", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1, { + jewelRadiusIndex = getTestRadiusIndex(), + }) + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) + assert.are.equal(1, #equippedList) + + local beforeSlotId = build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId + local beforeSpecJewel = build.spec.jewels[ALLOC_SOCKET_IDS[1]] + local beforeAllocKeys = {} + for nodeId, _ in pairs(build.spec.allocNodes) do + beforeAllocKeys[nodeId] = true + end + + finder:removeEquippedJewels(equippedList) + finder:restoreEquippedJewels(equippedList) + + assert.are.equal(beforeSlotId, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId) + assert.are.equal(beforeSpecJewel, build.spec.jewels[ALLOC_SOCKET_IDS[1]]) + for nodeId, _ in pairs(beforeAllocKeys) do + assert.is_not_nil(build.spec.allocNodes[nodeId], + "allocNode " .. nodeId .. " should be restored") + end + end) + + it("remove clears slot.selItemId and spec.jewels", function() + equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) + + finder:removeEquippedJewels(equippedList) + + assert.are.equal(0, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId) + assert.are.equal(0, build.spec.jewels[ALLOC_SOCKET_IDS[1]]) + + finder:restoreEquippedJewels(equippedList) + end) + + it("remove clears dependent disconnected passive nodes from allocNodes", function() + local smallRI = getTestRadiusIndex() + local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI) + if not testSocketId then pending("no isolated radius node found") end + + -- Allocate the isolated node as a disconnected passive jewel would. + build.spec.allocNodes[testSocketId] = build.spec.tree.nodes[testSocketId] + build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId] + + equipFakeJewel(testSocketId, "Intuitive Leap", 1, { + jewelRadiusIndex = smallRI, + }) + + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" }) + assert.are.equal(1, #equippedList) + + finder:removeEquippedJewels(equippedList) + assert.is_nil(build.spec.allocNodes[testNodeId], + "dependent node " .. testNodeId .. " should be removed") + + finder:restoreEquippedJewels(equippedList) + assert.is_not_nil(build.spec.allocNodes[testNodeId], + "dependent node " .. testNodeId .. " should be restored") + end) + + it("remove preserves nodes connected from outside the radius", function() + local treeData = build.spec.tree + local ri = getTestRadiusIndex() + local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri) + if not testSocketId then pending("no radius node with outside linked node found") end + + build.spec.allocNodes[testSocketId] = treeData.nodes[testSocketId] + build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId] + build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId] + + equipFakeJewel(testSocketId, "Intuitive Leap", 1, { + jewelRadiusIndex = ri, + }) + + local finder = makeFinder() + local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" }) + assert.are.equal(1, #equippedList) + + finder:removeEquippedJewels(equippedList) + assert.is_not_nil(build.spec.allocNodes[testNodeId], + "connected node " .. testNodeId .. " should NOT be removed") + + finder:restoreEquippedJewels(equippedList) + end) + + end) + + end) + +end) diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua new file mode 100644 index 0000000000..ef28210388 --- /dev/null +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -0,0 +1,356 @@ +-- 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("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 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 preview = RadiusJewelData.jewelPreviewFn["Intuitive Leap"](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.collectDisconnectedPassiveCandidates + function finder:collectDisconnectedPassiveCandidates(socketNode, options) + capturedOptions = options + return { } + end + local sockets = finder:buildJewelSockets(getSmallRadiusIndex()) + finder:computeIntuitiveLeapSocketImpact({ sockets[1] }, "Life", variant, "fast", { }, nil, nil, { id = "all" }) + finder.collectDisconnectedPassiveCandidates = originalCollect + + assert.is_not_nil(capturedOptions) + assert.is_true(capturedOptions.keystoneOnly) + assert.is_function(capturedOptions.collectNodes) + + local massiveRadiusIndex + for index, radius in ipairs(data.jewelRadius) do + if radius.outer > data.jewelRadius[getSmallRadiusIndex()].outer + and radius.outer <= RadiusJewelData.FULL_MASSIVE_RADIUS then + massiveRadiusIndex = index + break + end + end + assert.is_not_nil(massiveRadiusIndex, "expected a radius beyond Small and within Massive Radius") + local massiveKeystone = { id = "foulbornMassiveKeystone", type = "Keystone" } + local syntheticSocket = { + nodesInRadius = { + [getSmallRadiusIndex()] = { normalPassive = { id = "normalPassive", type = "Normal" } }, + [massiveRadiusIndex] = { foulbornMassiveKeystone = massiveKeystone }, + }, + } + local candidates = finder:collectDisconnectedPassiveCandidates(syntheticSocket, capturedOptions) + assert.are.same({ massiveKeystone }, candidates) + 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:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant) + computedVariants[#computedVariants + 1] = variant + return { + { + socket = sockets[1], + delta = variant.isFoulborn and 2 or 1, + addedNodeCount = 0, + }, + }, 100 + end + local results, baseline = finder:computeBestIntuitiveLeapSocketImpact({ { id = "testSocket" } }, "Life", intuitiveVariants, "fast", { }) + 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 index b99c0a9771..4e8bd7c8c2 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -1,169 +1,9 @@ --- Tests for RadiusJewelFinder: buildJewelSockets, computeBestVariantSocketImpact, computeSocketImpact --- --- Uses OccVortex (3.13 Occultist/Vortex) as reference build. --- Allocated jewel sockets in that build: 36634, 61419, 41263 (all occupied by jewels). --- All other sockets are unallocated and empty. - -local occVortex = LoadModule("../spec/TestBuilds/3.13/OccVortex.lua") -local mirageArcherToxicRain = LoadModule("../spec/TestBuilds/3.13/Mirage Archer Toxic Rain.lua") -local RadiusJewelData = LoadModule("Classes/RadiusJewelData") - -local MIGHT_OF_MEEK_RAW_TEXT = [[Might of the Meek -Crimson Jewel -Radius: Large -50% increased Effect of non-Keystone Passive Skills in Radius -Notable Passive Skills in Radius grant nothing]] - -local UNNATURAL_INSTINCT_RAW_TEXT = [[Unnatural Instinct -Viridian Jewel -Limited to: 1 -Radius: Small -Allocated Small Passive Skills in Radius grant nothing -Grants all bonuses of Unallocated Small Passive Skills in Radius]] - -local ANATOMICAL_KNOWLEDGE_RAW_TEXT = [[Anatomical Knowledge -Cobalt Jewel -Source: No longer obtainable -Radius: Large -8% increased maximum Life -Adds 1 to Maximum Life per 3 Intelligence Allocated in Radius]] - -local function buildSplitPersonalityRawText(modLine) - return table.concat({ - "Split Personality", - "Crimson Jewel", - "Variable", - "This Jewel's Socket has 25% increased effect per Allocated Passive Skill between it and your Class' starting location", - modLine, - "Corrupted", - }, "\n") -end - -local function buildImpossibleEscapeRawText(keystoneName) - return table.concat({ - "Impossible Escape", - "Viridian Jewel", - "Limited to: 1", - "Small", - "Passive Skills in radius of " .. keystoneName .. " can be allocated without being connected to your tree", - "Corrupted", - }, "\n") -end - --- ───────────────────────────────────────────────────────────────────────────── --- Helpers --- ───────────────────────────────────────────────────────────────────────────── - -local function makeFinder() - return new("RadiusJewelFinder"):RadiusJewelFinder({ build = build }) -end - -local function getLargeRadiusIndex() - local map = {} - for i, r in ipairs(data.jewelRadius) do - if r.inner == 0 and not map[r.label] then map[r.label] = i end - end - return map["Large"] -end - -local function getSmallRadiusIndex() - local map = {} - for i, r in ipairs(data.jewelRadius) do - if r.inner == 0 and not map[r.label] then map[r.label] = i end - end - return map["Small"] -end - -local function getRadiusIndexFromRawText(rawText) - local item = new("Item"):Item("Rarity: Unique\n" .. rawText) - return item.jewelRadiusIndex -end - -local function makeImpossibleEscapeTestVariant() - local smallRadiusIndex = getSmallRadiusIndex() - local allocNodes = build.spec.allocNodes - for keystoneName, node in pairs(build.spec.tree.keystoneMap or {}) do - if node and node.nodesInRadius and node.nodesInRadius[smallRadiusIndex] then - -- Ensure there is at least one unallocated candidate node - local hasCandidate = false - for nodeId, n in pairs(node.nodesInRadius[smallRadiusIndex]) do - if not allocNodes[nodeId] and not n.ascendancyName - and n.type ~= "Socket" and n.type ~= "ClassStart" - and n.type ~= "AscendClassStart" and n.type ~= "Mastery" then - hasCandidate = true - break - end - end - if hasCandidate then - return { - name = keystoneName, - keystoneName = keystoneName, - rawText = buildImpossibleEscapeRawText(keystoneName), - } - end - end - end -end - -local function makeThreadVariants() - local names = { "Small", "Medium", "Large", "Very Large", "Massive" } - local variants = {} - local idx = 1 - for radiusIndex, radius in ipairs(data.jewelRadius) do - if radius.inner > 0 then - variants[#variants + 1] = { - name = names[idx] or ("Ring " .. idx), - radiusIndex = radiusIndex, - } - idx = idx + 1 - end - end - return variants -end - -local function isSorted(results, key) - for i = 2, #results do - if results[i - 1][key] < results[i][key] then return false end - end - return true -end - -local function snapshotFinderState() - local socketSelItemIds = {} - for socketId, slot in pairs(build.itemsTab.sockets) do - socketSelItemIds[socketId] = slot.selItemId - end - - local itemOrderList = {} - for i, itemId in ipairs(build.itemsTab.itemOrderList) do - itemOrderList[i] = itemId - end - - local itemCount = 0 - for _ in pairs(build.itemsTab.items) do - itemCount = itemCount + 1 - end - - return { - socketSelItemIds = socketSelItemIds, - itemOrderList = itemOrderList, - itemCount = itemCount, - jewels = copyTable(build.spec.jewels, true), - } -end - -local function assertFinderStateUnchanged(before) - local after = snapshotFinderState() - assert.are.same(before.socketSelItemIds, after.socketSelItemIds) - assert.are.same(before.itemOrderList, after.itemOrderList) - assert.are.equal(before.itemCount, after.itemCount) - assert.are.same(before.jewels, after.jewels) -end - --- ───────────────────────────────────────────────────────────────────────────── --- Tests --- ───────────────────────────────────────────────────────────────────────────── +-- 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 describe("RadiusJewelFinder #radius-jewel", function() before_each(function() @@ -643,565 +483,6 @@ describe("RadiusJewelFinder #radius-jewel", function() 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("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 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 preview = RadiusJewelData.jewelPreviewFn["Intuitive Leap"](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.collectDisconnectedPassiveCandidates - function finder:collectDisconnectedPassiveCandidates(socketNode, options) - capturedOptions = options - return { } - end - local sockets = finder:buildJewelSockets(getSmallRadiusIndex()) - finder:computeIntuitiveLeapSocketImpact({ sockets[1] }, "Life", variant, "fast", { }, nil, nil, { id = "all" }) - finder.collectDisconnectedPassiveCandidates = originalCollect - - assert.is_not_nil(capturedOptions) - assert.is_true(capturedOptions.keystoneOnly) - assert.is_function(capturedOptions.collectNodes) - - local massiveRadiusIndex - for index, radius in ipairs(data.jewelRadius) do - if radius.outer > data.jewelRadius[getSmallRadiusIndex()].outer - and radius.outer <= RadiusJewelData.FULL_MASSIVE_RADIUS then - massiveRadiusIndex = index - break - end - end - assert.is_not_nil(massiveRadiusIndex, "expected a radius beyond Small and within Massive Radius") - local massiveKeystone = { id = "foulbornMassiveKeystone", type = "Keystone" } - local syntheticSocket = { - nodesInRadius = { - [getSmallRadiusIndex()] = { normalPassive = { id = "normalPassive", type = "Normal" } }, - [massiveRadiusIndex] = { foulbornMassiveKeystone = massiveKeystone }, - }, - } - local candidates = finder:collectDisconnectedPassiveCandidates(syntheticSocket, capturedOptions) - assert.are.same({ massiveKeystone }, candidates) - 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:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant) - computedVariants[#computedVariants + 1] = variant - return { - { - socket = sockets[1], - delta = variant.isFoulborn and 2 or 1, - addedNodeCount = 0, - }, - }, 100 - end - local results, baseline = finder:computeBestIntuitiveLeapSocketImpact({ { id = "testSocket" } }, "Life", intuitiveVariants, "fast", { }) - 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) - - -- ── 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():computeBestVariantSocketImpact(sockets, variants, "Life") - assert.is_true(#results > 0, "expected at least one result") - assert.is_true(#results <= #sockets, "should return no more than socket count") - assert.is_number(baseline) - assert.is_true(baseline > 0) - for _, r in ipairs(results) do - assert.is_not_nil(r.socket) - assert.is_not_nil(r.variant) - assert.is_string(r.variant.name) - assert.is_number(r.delta) - end - end) - - it("results are sorted by delta descending", function() - local sockets = getSockets() - local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") - assert.is_true(isSorted(results, "delta"), - "results should be sorted by delta descending") - end) - - it("Life variant selected on sockets where it is better than others", function() - local sockets = getSockets() - local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") - local hasLife = false - for _, r in ipairs(results) do - if r.variant.name == "Life" then hasLife = true; break end - end - assert.is_true(hasLife, "expected Life variant to be best for at least one socket") - end) - - it("restores TotalLife after compute", function() - local sockets = getSockets() - local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") - local after = build.calcsTab.mainOutput["Life"] - assert.are.equal(before, after) - end) - - it("restores socket and item state after compute", function() - local sockets = getSockets() - local before = snapshotFinderState() - makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") - assertFinderStateUnchanged(before) - end) - - it("respects occupiedMode filter", function() - local sockets = getSockets() - local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" }) - assert.is_true(#results > 0, "expected results with occupied mode 'all'") - end) - - end) - - 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():computeBestVariantSocketImpact({ { - id = socketId, - label = "Historic socket", - pathDist = 0, - } }, { { - name = "Candidate", - rawText = MIGHT_OF_MEEK_RAW_TEXT, - } }, "Life", nil, nil, { 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: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:computeIntuitiveLeapSocketImpact( - { testSocket }, "Life", nil, "fast", { }, nil, 0, { id = "all" }, 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():computeSplitPersonalitySocketImpact({ { - id = socketId, - label = "Historic socket", - classStartDist = splitDistance, - pathDist = 0, - } }, "Life", { { - name = "Dexterity", - rawText = buildSplitPersonalityRawText("+5 to Dexterity"), - } }, nil, nil, { 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():computeSplitPersonalitySocketImpact({ { - id = testSocket.id, - label = "Stored Historic socket", - classStartDist = 42, - pathDist = 1, - } }, "Life", { { - name = "Dexterity", - rawText = buildSplitPersonalityRawText("+5 to Dexterity"), - } }, nil, nil, { id = "all" }) - build.calcsTab.GetMiscCalculator = originalGetMiscCalculator - - assert.is_false(usedComparisonSpec) - end) - - end) - describe("replacement item tooltip", function() it("attaches the replaced jewel to its detail line", function() @@ -1240,1081 +521,4 @@ describe("RadiusJewelFinder #radius-jewel", function() end) - -- ── computeSocketImpact (MoM / UI / AK) ──────────────────────────────── - - describe("computeSocketImpact", function() - - local function getSockets() - return makeFinder():buildJewelSockets(getLargeRadiusIndex()) - end - - it("returns a table (may be empty if all sockets occupied)", function() - local results, baseline = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - assert.is_table(results) - assert.is_number(baseline) - end) - - it("returns the current main output as baseline for the selected stat", function() - local expectedBaseline = build.calcsTab.mainOutput["Life"] - local _, baseline = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - assert.are.equal(expectedBaseline, baseline) - end) - - it("returns at least one result for the fixture build", function() - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - assert.is_true(#results > 0, "expected at least one empty jewel socket result") - end) - - it("MoM: only tests empty sockets (selItemId == 0)", function() - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - for _, r in ipairs(results) do - local slot = build.itemsTab.sockets[r.socket.id] - assert.are.equal(0, slot.selItemId, - "result socket " .. r.socket.id .. " should be empty after compute") - end - end) - - it("MoM: results sorted by delta descending", function() - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - assert.is_true(isSorted(results, "delta"), - "MoM socket results should be sorted by delta descending") - end) - - it("MoM: restores TotalLife after compute", function() - local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - assert.are.equal(before, build.calcsTab.mainOutput["Life"]) - end) - - it("MoM: restores socket and item state after compute", function() - local before = snapshotFinderState() - makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - assertFinderStateUnchanged(before) - end) - - it("UI: restores TotalLife after compute", function() - local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "Life") - assert.are.equal(before, build.calcsTab.mainOutput["Life"]) - end) - - it("AK: restores TotalLife after compute", function() - local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "Life") - assert.are.equal(before, build.calcsTab.mainOutput["Life"]) - end) - - it("respects max total points for standard compute", function() - local maxPoints = 2 - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, maxPoints) - for _, r in ipairs(results) do - assert.is_true((r.socket.pathDist or 0) <= maxPoints, - "socket " .. r.socket.id .. " used too many points") - end - end) - - it("occupied sockets (36634, 61419, 41263) are skipped", function() - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } - for _, r in ipairs(results) do - assert.is_nil(occupiedIds[r.socket.id], - "occupied socket " .. r.socket.id .. " should not appear in results") - end - end) - - it("occupiedMode 'all' includes occupied sockets", function() - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) - local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } - local foundOccupied = false - for _, r in ipairs(results) do - if occupiedIds[r.socket.id] then foundOccupied = true; break end - end - assert.is_true(foundOccupied, - "expected at least one occupied socket in results with mode 'all'") - end) - - it("occupiedMode 'safe' returns at least as many results as 'free'", function() - local sockets = getSockets() - local freeResults, _ = makeFinder():computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") - local safeResults, _ = makeFinder():computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "safe" }) - assert.is_true(#safeResults >= #freeResults, - "safe mode should include at least all free sockets") - end) - - it("occupiedMode 'all' returns more results than 'free' (build has occupied sockets)", function() - local sockets = getSockets() - local freeResults, _ = makeFinder():computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") - local allResults, _ = makeFinder():computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) - assert.is_true(#allResults > #freeResults, - "all mode should include more sockets than free mode (occupied sockets exist)") - end) - - it("each result has socket, value and delta fields", function() - local results, _ = makeFinder():computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") - local seenSocketIds = {} - for _, r in ipairs(results) do - assert.is_not_nil(r.socket) - assert.is_number(r.socket.id) - assert.is_number(r.value) - assert.is_number(r.delta) - assert.is_nil(seenSocketIds[r.socket.id], - "duplicate socket result for socket " .. r.socket.id) - seenSocketIds[r.socket.id] = true - end - end) - - end) - - describe("disconnected passive max total points", function() - - local function getSockets() - return makeFinder():buildJewelSockets(getLargeRadiusIndex()) - end - - it("respects max total points for Intuitive Leap", function() - local maxPoints = 4 - local results, _ = makeFinder():computeIntuitiveLeapSocketImpact( - getSockets(), "Life", false, "simulated_greedy", { }, nil, maxPoints) - for _, r in ipairs(results) do - local totalPoints = (r.socket.pathDist or 0) + (r.addedNodeCount or 0) - assert.is_true(totalPoints <= maxPoints, - "socket " .. r.socket.id .. " plan used too many points") - end - end) - - it("stops at jewel-only when the socket already uses all max points", function() - local targetSocket - for _, socket in ipairs(getSockets()) do - if socket.pathDist and socket.pathDist > 0 then - targetSocket = socket - break - end - end - assert.is_not_nil(targetSocket, "expected at least one socket with path points") - local maxPoints = targetSocket.pathDist - local sockets = { targetSocket } - local fastResults = makeFinder():computeIntuitiveLeapSocketImpact( - sockets, "Life", false, "fast", { }, nil, maxPoints) - local simulatedResults = makeFinder():computeIntuitiveLeapSocketImpact( - sockets, "Life", false, "simulated_greedy", { }, nil, maxPoints) - assert.are.equal(0, fastResults[1].addedNodeCount or 0) - assert.are.equal(0, simulatedResults[1].addedNodeCount or 0) - end) - - end) - - describe("computeSplitPersonalitySocketImpact", function() - - local function getSockets() - return makeFinder():buildJewelSockets(getLargeRadiusIndex()) - end - - local variants = { - { name = "Life", rawText = buildSplitPersonalityRawText("+5 to maximum Life") }, - { name = "Mana", rawText = buildSplitPersonalityRawText("+5 to maximum Mana") }, - } - - it("returns results and restores socket distance state", function() - local sockets = getSockets() - local before = snapshotFinderState() - local previousDistanceBySocketId = {} - for _, socket in ipairs(sockets) do - previousDistanceBySocketId[socket.id] = build.spec.nodes[socket.id] and build.spec.nodes[socket.id].distanceToClassStart - end - - local results, baseline = makeFinder():computeSplitPersonalitySocketImpact(sockets, "Life", variants) - - assert.is_true(#results > 0, "expected split personality results") - assert.is_number(baseline) - for _, result in ipairs(results) do - assert.is_not_nil(result.variant) - assert.is_number(result.splitDistance) - assert.is_string(result.detailText) - end - for _, socket in ipairs(sockets) do - local node = build.spec.nodes[socket.id] - assert.are.equal(previousDistanceBySocketId[socket.id], node and node.distanceToClassStart) - end - assertFinderStateUnchanged(before) - end) - - it("respects max total points", function() - local maxPoints = 4 - local results, _ = makeFinder():computeSplitPersonalitySocketImpact( - getSockets(), "Life", variants, nil, maxPoints) - for _, result in ipairs(results) do - local totalPoints = (result.socket.pathDist or 0) - assert.is_true(totalPoints <= maxPoints, - "socket " .. result.socket.id .. " plan used too many points") - end - end) - - end) - - describe("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():computeBestVariantSocketImpact({ { - id = socketId, - label = "Cluster socket", - pathDist = 0, - } }, { { - name = "Candidate", - rawText = MIGHT_OF_MEEK_RAW_TEXT, - } }, "Life", nil, nil, { 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 - - it("shares fast cache keys except for structural jewel replacements", function() - local finder = makeFinder() - local sharedKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { - socketNode = { id = 36634 }, - occupancy = { isOccupied = false }, - }) - local structuralItem = { - type = "Jewel", - jewelData = { conqueredBy = true }, - } - local firstStructuralKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { - socketNode = { id = 36634 }, - occupancy = { isOccupied = true, item = structuralItem }, - }) - local secondStructuralKey = finder: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.collectDisconnectedPassiveCandidates - local originalBuildOverride = finder.buildSocketReplacementOverride - local originalCacheKey = finder.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.collectDisconnectedPassiveCandidates = function() - return { - { id = -101, name = "First" }, - { id = -102, name = "Second" }, - { id = -103, name = "Third" }, - } - end - finder.buildSocketReplacementOverride = function(_, _, _, addNodes) - return { addNodes = addNodes } - end - - local function countCalculations(cacheKeyFunc) - finder.getImpossibleEscapePlanCacheKey = cacheKeyFunc - calculationCount = 0 - finder:computeImpossibleEscapeSocketImpact(sockets, "Life", { variant }, "fast", { }, nil, 2, nil, 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.collectDisconnectedPassiveCandidates = originalCollectCandidates - finder.buildSocketReplacementOverride = originalBuildOverride - finder.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 = makeFinder():computeImpossibleEscapeSocketImpact( - sockets, "Life", { variant }, "fast", { }, nil) - local simulatedResults, simulatedBaseline = makeFinder():computeImpossibleEscapeSocketImpact( - sockets, "Life", { variant }, "simulated_greedy", { }, nil) - - assert.is_true(#fastResults > 0, "expected fast Impossible Escape results") - assert.is_true(#simulatedResults > 0, "expected simulated Impossible Escape results") - assert.is_number(fastBaseline) - assert.are.equal(fastBaseline, simulatedBaseline) - assert.are.equal(variant.name, fastResults[1].variant.name) - assert.are.equal(variant.name, simulatedResults[1].variant.name) - assertFinderStateUnchanged(before) - end) - - it("respects max total points", function() - local variant = makeImpossibleEscapeTestVariant() - assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant") - local maxPoints = 4 - local results, _ = makeFinder():computeImpossibleEscapeSocketImpact( - getSockets(), "Life", { variant }, "simulated_greedy", { }, nil, maxPoints) - for _, result in ipairs(results) do - local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) - assert.is_true(totalPoints <= maxPoints, - "socket " .. result.socket.id .. " plan used too many points") - end - end) - - end) - - describe("computeThreadOfHopeSocketImpact", function() - - local function getSockets() - return makeFinder():buildJewelSockets(getLargeRadiusIndex()) - end - - local function getTestVariants() - local threadVariants = makeThreadVariants() - return { threadVariants[1], threadVariants[2] or threadVariants[1] } - end - - local function getTestSockets(threadVariants) - for _, socket in ipairs(getSockets()) do - local slot = build.itemsTab.sockets[socket.id] - local node = build.spec.tree.nodes[socket.id] - if slot and slot.selItemId == 0 and node and node.nodesInRadius then - for _, variant in ipairs(threadVariants) do - local radiusNodes = node.nodesInRadius[variant.radiusIndex] - if radiusNodes and next(radiusNodes) then - return { socket } - end - end - end - end - return { getSockets()[1] } - end - - it("returns results for both methods without changing finder state", function() - local threadVariants = getTestVariants() - assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") - local sockets = getTestSockets(threadVariants) - local before = snapshotFinderState() - - local fastResults, fastBaseline = makeFinder():computeThreadOfHopeSocketImpact( - sockets, "Life", threadVariants, "fast", { }, nil) - local simulatedResults, simulatedBaseline = makeFinder():computeThreadOfHopeSocketImpact( - sockets, "Life", threadVariants, "simulated_greedy", { }, nil) - - assert.is_true(#fastResults > 0, "expected fast Thread of Hope results") - assert.is_true(#simulatedResults > 0, "expected simulated Thread of Hope results") - assert.is_number(fastBaseline) - assert.are.equal(fastBaseline, simulatedBaseline) - assert.is_not_nil(fastResults[1].variant) - assert.is_not_nil(simulatedResults[1].variant) - assert.is_number(fastResults[1].variant.radiusIndex) - assert.is_number(simulatedResults[1].variant.radiusIndex) - assert.is_string(fastResults[1].detailText) - assert.is_string(simulatedResults[1].detailText) - assertFinderStateUnchanged(before) - end) - - it("respects max total points", function() - local threadVariants = getTestVariants() - assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") - local maxPoints = 4 - local results, _ = makeFinder():computeThreadOfHopeSocketImpact( - getTestSockets(threadVariants), "Life", threadVariants, "simulated_greedy", { }, nil, maxPoints) - for _, result in ipairs(results) do - local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) - assert.is_true(totalPoints <= maxPoints, - "socket " .. result.socket.id .. " plan used too many points") - end - end) - - end) - - -- ── Jewel limit parsing ───────────────────────────────────────────────── - - describe("jewel limit parsing from raw text", function() - - it("parses Limited to: 1 from Impossible Escape raw text", function() - local rawText = buildImpossibleEscapeRawText("Acrobatics") - local limitKey = rawText:match("^([^\n]+)") - local limit = tonumber(rawText:match("Limited to: (%d+)")) - assert.are.equals("Impossible Escape", limitKey) - assert.are.equals(1, limit) - end) - - it("parses Limited to: 1 from Unnatural Instinct raw text", function() - local limitKey = UNNATURAL_INSTINCT_RAW_TEXT:match("^([^\n]+)") - local limit = tonumber(UNNATURAL_INSTINCT_RAW_TEXT:match("Limited to: (%d+)")) - assert.are.equals("Unnatural Instinct", limitKey) - assert.are.equals(1, limit) - end) - - it("returns nil limit for jewels without Limited to", function() - local limit = tonumber(MIGHT_OF_MEEK_RAW_TEXT:match("Limited to: (%d+)")) - assert.is_nil(limit) - end) - - end) - - -- ── filterBestPerSocket ──────────────────────────────────────────────── - - describe("filterBestPerSocket", function() - - local function makeRow(socketId, score, options) - options = options or {} - return { - socketId = socketId, - sortValue = score, - isSocketIndependent = options.isSocketIndependent, - jewelLimitKey = options.jewelLimitKey, - jewelLimit = options.jewelLimit, - points = options.points, - name = options.name or ("row-" .. socketId), - } - end - - it("keeps one result per socket, highest score is kept", function() - local rows = { - makeRow(1, 10, { name = "A" }), - makeRow(1, 20, { name = "B" }), - makeRow(2, 15, { name = "C" }), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(2, #result) - local ids = {} - for _, r in ipairs(result) do ids[r.socketId] = r.name end - assert.are.equal("B", ids[1]) - assert.are.equal("C", ids[2]) - end) - - it("results are sorted by score descending", function() - local rows = { - makeRow(1, 5), - makeRow(2, 30), - makeRow(3, 15), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(3, #result) - assert.are.equal(2, result[1].socketId) - assert.are.equal(3, result[2].socketId) - assert.are.equal(1, result[3].socketId) - end) - - it("applies jewelLimit per jewelLimitKey", function() - local rows = { - makeRow(1, 30, { jewelLimitKey = "IE", jewelLimit = 1 }), - makeRow(2, 20, { jewelLimitKey = "IE", jewelLimit = 1 }), - makeRow(3, 10), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(2, #result) - local ids = {} - for _, r in ipairs(result) do ids[r.socketId] = true end - assert.is_true(ids[1], "best IE should be kept") - assert.is_true(ids[3], "unlimited jewel should be kept") - assert.is_nil(ids[2], "second IE should be dropped (limit 1)") - end) - - it("allows multiple copies up to the limit", function() - local rows = { - makeRow(1, 30, { jewelLimitKey = "CF", jewelLimit = 2 }), - makeRow(2, 20, { jewelLimitKey = "CF", jewelLimit = 2 }), - makeRow(3, 10, { jewelLimitKey = "CF", jewelLimit = 2 }), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(2, #result) - assert.are.equal(1, result[1].socketId) - assert.are.equal(2, result[2].socketId) - end) - - it("socket-dependent jewels are assigned before socket-independent", function() - -- Socket 1: dependent score 10, independent score 20 - -- The dependent should get socket 1, independent goes to socket 2 - local rows = { - makeRow(1, 10, { name = "dependent" }), - makeRow(1, 20, { name = "independent", isSocketIndependent = true }), - makeRow(2, 5, { name = "independent2", isSocketIndependent = true }), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(2, #result) - local bySocket = {} - for _, r in ipairs(result) do bySocket[r.socketId] = r.name end - -- The independent with score 20 cannot take socket 1 (dependent uses it) - -- It should go to socket 2 instead - assert.are.equal("dependent", bySocket[1]) - end) - - it("socket-independent jewels use remaining sockets after dependent allocation", function() - local rows = { - makeRow(1, 30, { name = "dependent-1" }), - makeRow(2, 25, { name = "dependent-2" }), - makeRow(1, 20, { name = "independent-1", isSocketIndependent = true }), - makeRow(2, 15, { name = "independent-2", isSocketIndependent = true }), - makeRow(3, 10, { name = "independent-3", isSocketIndependent = true }), - } - local result = makeFinder():filterBestPerSocket(rows) - local bySocket = {} - for _, r in ipairs(result) do bySocket[r.socketId] = r.name end - assert.are.equal("dependent-1", bySocket[1]) - assert.are.equal("dependent-2", bySocket[2]) - assert.are.equal("independent-3", bySocket[3]) - end) - - it("socket-independent tie-break uses fewer points", function() - local rows = { - makeRow(1, 20, { isSocketIndependent = true, points = 5 }), - makeRow(2, 20, { isSocketIndependent = true, points = 2 }), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(2, #result) - -- Both are kept (different sockets), but fewer points should come first at equal score - -- Actually both have different sockets so both are included - -- The tie-break matters when multiple rows can use the same remaining sockets - end) - - it("socket-independent tie-break: at equal score, fewer points is kept", function() - -- Two independent jewels can use a single remaining socket - local rows = { - makeRow(1, 50, { name = "dependent" }), -- takes socket 1 - makeRow(1, 20, { name = "ie-high-points", isSocketIndependent = true, points = 8 }), - makeRow(2, 20, { name = "ie-low-points", isSocketIndependent = true, points = 2 }), - } - local result = makeFinder():filterBestPerSocket(rows) - local bySocket = {} - for _, r in ipairs(result) do bySocket[r.socketId] = r.name end - assert.are.equal("dependent", bySocket[1]) - assert.are.equal("ie-low-points", bySocket[2]) - end) - - it("limits are shared between dependent and independent jewels", function() - -- IE limited to 1: if a dependent row with same limitKey is placed first, - -- independent rows with that key are blocked - local rows = { - makeRow(1, 30, { name = "dependent-ie", jewelLimitKey = "IE", jewelLimit = 1 }), - makeRow(2, 20, { name = "independent-ie", isSocketIndependent = true, jewelLimitKey = "IE", jewelLimit = 1 }), - makeRow(3, 10, { name = "other" }), - } - local result = makeFinder():filterBestPerSocket(rows) - assert.are.equal(2, #result) - local names = {} - for _, r in ipairs(result) do names[r.name] = true end - assert.is_true(names["dependent-ie"]) - assert.is_true(names["other"]) - assert.is_nil(names["independent-ie"], "second IE should be blocked by shared limit") - end) - - it("returns empty table for empty input", function() - local result = makeFinder():filterBestPerSocket({}) - assert.are.equal(0, #result) - end) - - it("does not change the input rows table", function() - local rows = { - makeRow(2, 10), - makeRow(1, 20), - } - local originalLen = #rows - local originalFirst = rows[1] - makeFinder():filterBestPerSocket(rows) - assert.are.equal(originalLen, #rows) - assert.are.equal(originalFirst, rows[1]) - end) - - end) - - -- ── Move-aware compute helpers ───────────────────────────────────────── - - describe("move-aware compute helpers", function() - - local ALLOC_SOCKET_IDS = { 36634, 61419, 41263 } - - local function findUnallocatedSocketId() - for socketId, socketData in pairs(build.spec.nodes) do - if socketData.isJewelSocket and socketData.name ~= "Charm Socket" - and build.itemsTab.sockets[socketId] and not build.spec.allocNodes[socketId] then - return socketId - end - end - error("expected at least one unallocated jewel socket") - end - - local function equipFakeJewel(socketId, title, limit, extraItemFields) - local slot = build.itemsTab.sockets[socketId] - assert.is_not_nil(slot, "socket " .. socketId .. " should exist") - local fakeItemId = 999000 + socketId - local item = { title = title, limit = limit } - if extraItemFields then - for k, v in pairs(extraItemFields) do item[k] = v end - end - build.itemsTab.items[fakeItemId] = item - slot.selItemId = fakeItemId - build.spec.jewels[socketId] = fakeItemId - return item, fakeItemId - end - - local function getTestRadiusIndex() - return getLargeRadiusIndex() - end - - -- Find a jewel socket whose radius contains at least one unallocated node - -- with NO allocated linked nodes outside the radius ("isolated"). - -- Note: `linked` is on spec.nodes, not spec.tree.nodes. - local function findIsolatedRadiusNode(radiusIndex) - local treeData = build.spec.tree - for socketId, socketData in pairs(build.spec.nodes) do - if socketData.isJewelSocket then - local socketNode = treeData.nodes[socketId] - if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then - local radiusNodes = socketNode.nodesInRadius[radiusIndex] - for nodeId, _ in pairs(radiusNodes) do - if not build.spec.allocNodes[nodeId] then - local specNode = build.spec.nodes[nodeId] - local isolated = true - if specNode and specNode.linked then - for _, other in ipairs(specNode.linked) do - if build.spec.allocNodes[other.id] and not radiusNodes[other.id] then - isolated = false - break - end - end - end - if isolated then - return socketId, nodeId - end - end - end - end - end - end - end - - -- Find an unallocated radius node that has at least one linked node - -- OUTSIDE the radius. Returns socketId, nodeId, outsideLinkedNodeId. - -- Note: `linked` is on spec.nodes, not spec.tree.nodes. - local function findRadiusNodeWithOutsideLinkedNode(radiusIndex) - local treeData = build.spec.tree - for socketId, socketData in pairs(build.spec.nodes) do - if socketData.isJewelSocket then - local socketNode = treeData.nodes[socketId] - if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then - local radiusNodes = socketNode.nodesInRadius[radiusIndex] - for nodeId, _ in pairs(radiusNodes) do - if not build.spec.allocNodes[nodeId] then - local specNode = build.spec.nodes[nodeId] - if specNode and specNode.linked then - for _, other in ipairs(specNode.linked) do - if not radiusNodes[other.id] then - return socketId, nodeId, other.id - end - end - end - end - end - end - end - end - end - - -- ── findEquippedJewelSockets ──────────────────────────────────── - - describe("findEquippedJewelSockets", function() - - it("returns empty when no jewel of that type is equipped", function() - local result = makeFinder():findEquippedJewelSockets({ name = "Thread of Hope" }) - assert.are.equal(0, #result) - end) - - it("ignores jewels stored in unallocated sockets", function() - local socketId = findUnallocatedSocketId() - equipFakeJewel(socketId, "Thread of Hope", 1) - local finder = makeFinder() - local occupancy = finder:getSocketOccupancyInfo(socketId) - local allowed = finder:socketMatchesOccupiedMode(socketId, { id = "free" }) - - assert.is_false(occupancy.isOccupied) - assert.are.equal("Thread of Hope", occupancy.storedUnallocatedItemLabel) - assert.is_true(allowed) - assert.are.equal(7, finder:getSocketBasePoints({ id = socketId, pathDist = 7 }, occupancy)) - - local result = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) - assert.are.equal(0, #result) - assert.is_false(result.atLimit) - end) - - it("returns entry but atLimit=false when equipped jewel has no limit", function() - equipFakeJewel(ALLOC_SOCKET_IDS[1], "Might of the Meek", nil) - local result = makeFinder():findEquippedJewelSockets({ name = "Might of the Meek" }) - assert.are.equal(1, #result) - assert.are.equal(ALLOC_SOCKET_IDS[1], result[1].socketId) - assert.is_false(result.atLimit) - end) - - it("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: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("returns entry but atLimit=false when equipped count is below limit", function() - equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2) - local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" }) - assert.are.equal(1, #result, "1 equipped < limit 2") - assert.is_false(result.atLimit) - end) - - it("returns all entries with atLimit=true when count equals limit", function() - equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2) - equipFakeJewel(ALLOC_SOCKET_IDS[2], "Combat Focus", 2) - local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" }) - assert.are.equal(2, #result) - assert.is_true(result.atLimit) - end) - - it("does not match jewels with different title", function() - equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) - local result = makeFinder():findEquippedJewelSockets({ name = "Impossible Escape" }) - assert.are.equal(0, #result) - end) - - end) - - it("computeSocketImpact treats jewels stored in unallocated sockets as free sockets", function() - local socketId = findUnallocatedSocketId() - equipFakeJewel(socketId, "Unnatural Instinct", 1) - local finder = makeFinder() - local results = finder:computeSocketImpact({ - { id = socketId, label = "Test socket", pathDist = 7 }, - }, MIGHT_OF_MEEK_RAW_TEXT, "Life", nil, nil, { id = "free" }) - - assert.are.equal(1, #results) - assert.is_nil(results[1].replacedItemLabel) - assert.are.equal("Unnatural Instinct", results[1].storedUnallocatedItemLabel) - end) - - -- ── findDisconnectedPassiveDependentNodes ───────────────────────────── - - describe("findDisconnectedPassiveDependentNodes", function() - - it("returns empty for items without disconnected passive properties", function() - local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], { title = "Might of the Meek" }) - assert.are.equal(0, #result) - end) - - it("returns empty for invalid socketId", function() - local item = { jewelRadiusIndex = getTestRadiusIndex() } - local result = makeFinder():findDisconnectedPassiveDependentNodes(999999, item) - assert.are.equal(0, #result) - end) - - it("returns empty when no nodes are allocated in radius", function() - local treeData = build.spec.tree - local smallRI = getTestRadiusIndex() - local testSocketId - for socketId, _ in pairs(build.itemsTab.sockets) do - local node = treeData.nodes[socketId] - if node and node.nodesInRadius and node.nodesInRadius[smallRI] - and next(node.nodesInRadius[smallRI]) then - local hasAllocated = false - for nodeId, _ in pairs(node.nodesInRadius[smallRI]) do - if build.spec.allocNodes[nodeId] then - hasAllocated = true - break - end - end - if not hasAllocated then - testSocketId = socketId - break - end - end - end - if not testSocketId then pending("no empty radius socket found") end - local item = { jewelRadiusIndex = smallRI } - local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) - assert.are.equal(0, #result) - end) - - it("returns isolated allocated nodes in radius as dependent", function() - local smallRI = getTestRadiusIndex() - local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI) - if not testSocketId then pending("no isolated radius node found") end - - build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId] - - local item = { jewelRadiusIndex = smallRI } - local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) - - assert.is_true(#result > 0, "expected at least one dependent node") - local found = false - for _, nodeId in ipairs(result) do - if nodeId == testNodeId then found = true; break end - end - assert.is_true(found, "expected node " .. testNodeId .. " in dependent nodes") - end) - - it("excludes nodes connected from outside the radius", function() - local treeData = build.spec.tree - local ri = getTestRadiusIndex() - local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri) - if not testSocketId then pending("no radius node with outside linked node found") end - - -- Allocate both the radius node and its outside linked node - build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId] - build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId] - - local item = { jewelRadiusIndex = ri } - local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item) - - local found = false - for _, nodeId in ipairs(result) do - if nodeId == testNodeId then found = true; break end - end - assert.is_false(found, "node connected from outside radius should not be dependent") - end) - - it("handles IE keystoneMap path", function() - local variant = makeImpossibleEscapeTestVariant() - if not variant then pending("no IE keystone variant found") end - - local item = { - jewelData = { impossibleEscapeKeystones = { [variant.keystoneName] = true } }, - } - -- Should return empty since no extra nodes are allocated in the keystone radius - local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], item) - assert.is_table(result) - end) - - end) - - -- ── removeEquippedJewels / restoreEquippedJewels ──────────────── - - describe("removeEquippedJewels / restoreEquippedJewels", function() - - it("remove+restore keeps state identical", function() - equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1, { - jewelRadiusIndex = getTestRadiusIndex(), - }) - local finder = makeFinder() - local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) - assert.are.equal(1, #equippedList) - - local beforeSlotId = build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId - local beforeSpecJewel = build.spec.jewels[ALLOC_SOCKET_IDS[1]] - local beforeAllocKeys = {} - for nodeId, _ in pairs(build.spec.allocNodes) do - beforeAllocKeys[nodeId] = true - end - - finder:removeEquippedJewels(equippedList) - finder:restoreEquippedJewels(equippedList) - - assert.are.equal(beforeSlotId, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId) - assert.are.equal(beforeSpecJewel, build.spec.jewels[ALLOC_SOCKET_IDS[1]]) - for nodeId, _ in pairs(beforeAllocKeys) do - assert.is_not_nil(build.spec.allocNodes[nodeId], - "allocNode " .. nodeId .. " should be restored") - end - end) - - it("remove clears slot.selItemId and spec.jewels", function() - equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1) - local finder = makeFinder() - local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" }) - - finder:removeEquippedJewels(equippedList) - - assert.are.equal(0, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId) - assert.are.equal(0, build.spec.jewels[ALLOC_SOCKET_IDS[1]]) - - finder:restoreEquippedJewels(equippedList) - end) - - it("remove clears dependent disconnected passive nodes from allocNodes", function() - local smallRI = getTestRadiusIndex() - local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI) - if not testSocketId then pending("no isolated radius node found") end - - -- Allocate the isolated node as a disconnected passive jewel would. - build.spec.allocNodes[testSocketId] = build.spec.tree.nodes[testSocketId] - build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId] - - equipFakeJewel(testSocketId, "Intuitive Leap", 1, { - jewelRadiusIndex = smallRI, - }) - - local finder = makeFinder() - local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" }) - assert.are.equal(1, #equippedList) - - finder:removeEquippedJewels(equippedList) - assert.is_nil(build.spec.allocNodes[testNodeId], - "dependent node " .. testNodeId .. " should be removed") - - finder:restoreEquippedJewels(equippedList) - assert.is_not_nil(build.spec.allocNodes[testNodeId], - "dependent node " .. testNodeId .. " should be restored") - end) - - it("remove preserves nodes connected from outside the radius", function() - local treeData = build.spec.tree - local ri = getTestRadiusIndex() - local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri) - if not testSocketId then pending("no radius node with outside linked node found") end - - build.spec.allocNodes[testSocketId] = treeData.nodes[testSocketId] - build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId] - build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId] - - equipFakeJewel(testSocketId, "Intuitive Leap", 1, { - jewelRadiusIndex = ri, - }) - - local finder = makeFinder() - local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" }) - assert.are.equal(1, #equippedList) - - finder:removeEquippedJewels(equippedList) - assert.is_not_nil(build.spec.allocNodes[testNodeId], - "connected node " .. testNodeId .. " should NOT be removed") - - finder:restoreEquippedJewels(equippedList) - end) - - end) - - end) - end) From 117b479bb690c059f02630a21cf453931140762a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 12:28:09 +0200 Subject: [PATCH 27/52] Spell out radius jewel point labels --- manifest.xml | 4 ++-- spec/System/TestRadiusJewelFinder_spec.lua | 12 ++++++++++-- src/Classes/RadiusJewelFinder.lua | 10 +++++----- src/Classes/RadiusJewelResultsListControl.lua | 16 ++++++++-------- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/manifest.xml b/manifest.xml index b8a13c5de7..e4e5fd9d1e 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,8 +174,8 @@ - - + + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 4e8bd7c8c2..644a262fba 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -178,16 +178,24 @@ describe("RadiusJewelFinder #radius-jewel", function() 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 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 pts tooltip content") + assert.is_true(#maxPointsTooltipTexts > 0, "expected Max points tooltip content") assert.is_true(maxPointsTooltipTexts[1]:find("total passive points", 1, true) ~= nil, - "expected Max pts tooltip to explain total point limit") + "expected Max points tooltip to explain total point limit") + 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, diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index f83dd12f63..db8da019aa 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -1721,8 +1721,8 @@ end controls.impactStatLabel.shown = true controls.impactStatSelect.shown = true - controls.maxPointsLabel = new("LabelControl"):LabelControl(BL, { edgePadding + 110, bottomLabelY, 0, 16 }, "^7Max pts:") - controls.maxPointsEdit = new("EditControl"):EditControl(BL, { edgePadding + 172, bottomInputY, 56, buttonHeight }, tostring(selectedMaxPoints), nil, "%D", 3, function(buf) + 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) cancelCompute() selectedMaxPoints = buf ~= "" and tonumber(buf) or nil saveFinderState() @@ -1737,8 +1737,8 @@ end controls.maxPointsLabel.shown = true controls.maxPointsEdit.shown = true - controls.occupiedModeLabel = new("LabelControl"):LabelControl(BL, { edgePadding + 240, bottomLabelY, 0, 16 }, "^7Sockets:") - controls.occupiedModeSelect = new("DropDownControl"):DropDownControl(BL, { edgePadding + 298, bottomInputY, 170, buttonHeight }, occupiedModeLabels, function(idx) + 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) cancelCompute() selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[idx] saveFinderState() @@ -2187,7 +2187,7 @@ end else tooltip:AddLine(16, "^7Rank compatible sockets by the selected stat.") end - tooltip:AddLine(16, "^8Uses Stat, Max pts, and Sockets filters.") + tooltip:AddLine(16, "^8Uses Stat, Max points, and Sockets filters.") end controls.computeButton.shown = true diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua index 235b993f3d..5124e663fe 100644 --- a/src/Classes/RadiusJewelResultsListControl.lua +++ b/src/Classes/RadiusJewelResultsListControl.lua @@ -81,35 +81,35 @@ function RadiusJewelResultsListClass:RadiusJewelResultsListControl(anchor, rect, }, computeSocket = { { width = 170, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, + { width = 50, label = "Points", sortable = true }, { width = 75, label = "Gain", sortable = true }, { width = 60, label = "%", sortable = true }, { width = 65, label = "%/Pt", sortable = true }, - { width = 150, label = "Detail", sortable = true }, + { width = 140, label = "Detail", sortable = true }, }, computeSocketAll = { { width = 120, label = "Jewel", sortable = true }, { width = 130, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, + { width = 50, label = "Points", sortable = true }, { width = 75, label = "Gain", sortable = true }, { width = 60, label = "%", sortable = true }, { width = 65, label = "%/Pt", sortable = true }, - { width = 70, label = "Detail", sortable = true }, + { width = 60, label = "Detail", sortable = true }, }, find = { { width = 170, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, + { width = 50, label = "Points", sortable = true }, { width = 60, label = "Score", sortable = true }, { width = 70, label = "/Pt", sortable = true }, - { width = 220, label = "Detail", sortable = true }, + { width = 210, label = "Detail", sortable = true }, }, findThread = { { width = 170, label = "Socket", sortable = true }, - { width = 40, label = "Pts", sortable = true }, + { width = 50, label = "Points", sortable = true }, { width = 60, label = "Score", sortable = true }, { width = 70, label = "/Pt", sortable = true }, { width = 90, label = "Ring", sortable = true }, - { width = 130, label = "Detail", sortable = true }, + { width = 120, label = "Detail", sortable = true }, }, } self.defaultSortByMode = { From 63d5e6818ee536b7457b8a11b6df03f24b593f0c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 12:49:50 +0200 Subject: [PATCH 28/52] Reduce radius jewel result cache memory Drop nested requirement source objects from tooltip snapshots because stat comparison tooltips consume only scalar values. Cover both stored snapshots with a regression test. --- manifest.xml | 2 +- spec/System/TestRadiusJewelCompute_spec.lua | 15 +++++++++++++++ src/Classes/RadiusJewelFinder.lua | 13 ++----------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/manifest.xml b/manifest.xml index e4e5fd9d1e..1d33f7931e 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 7a8774bea0..2e1f82bd31 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -54,6 +54,21 @@ describe("RadiusJewelCompute #radius-jewel", function() end end) + it("keeps comparison snapshots free of nested requirement sources", function() + local results = makeFinder():computeBestVariantSocketImpact(getSockets(), getLightOfMeaningVariants(), "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():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index db8da019aa..20773240b3 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -18,10 +18,8 @@ local COL_META = RadiusJewelData.COL_META local FULL_MASSIVE_RADIUS = RadiusJewelData.FULL_MASSIVE_RADIUS -- Small output snapshot for stat-comparison tooltips. --- Copies only scalar fields and the small tables needed by --- AddStatComparesToTooltip / AddRequirementWarningsToTooltip, --- skipping heavy sub-tables (SkillDPS, env, modDB, etc.) --- that would otherwise cause multi-GB memory usage. +-- Copies scalar fields and compact Minion output while skipping nested +-- calculation and requirement-source tables that retain large object graphs. local function extractTooltipStats(output) if not output then return nil end local out = {} @@ -31,13 +29,6 @@ local function extractTooltipStats(output) out[k] = v end end - -- Requirement fail lists (small tables with source references) - for _, key in ipairs({"ReqStrFailList", "ReqDexFailList", "ReqIntFailList", "ReqOmniFailList", - "ReqStrItem", "ReqDexItem", "ReqIntItem", "ReqOmniItem"}) do - if output[key] then - out[key] = output[key] - end - end -- Copy minion stats with the same scalar-only treatment. if output.Minion then out.Minion = extractTooltipStats(output.Minion) From eb69dce58bd655a39f4eeb84f85f51756405c00e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 14:09:41 +0200 Subject: [PATCH 29/52] Remove unused radius jewel module surface Keep the data module API limited to active consumers and normalize popup wiring indentation for review. --- manifest.xml | 4 +- src/Classes/RadiusJewelData.lua | 35 +-- src/Classes/RadiusJewelFinder.lua | 365 +++++++++++++++--------------- 3 files changed, 190 insertions(+), 214 deletions(-) diff --git a/manifest.xml b/manifest.xml index 1d33f7931e..39680d1538 100644 --- a/manifest.xml +++ b/manifest.xml @@ -172,9 +172,9 @@ - + - + diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 2fa6f5ab4a..b1c22146c2 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -19,15 +19,11 @@ M.FULL_MASSIVE_RADIUS = 2400 -- Color constants -- ───────────────────────────────────────────────────────────────────────────── -M.COL_UNIQUE = "^xAF6025" -M.COL_MOD = "^7" -M.COL_META = "^8" -M.COL_NEG = "^1" - -local COL_UNIQUE = M.COL_UNIQUE -local COL_MOD = M.COL_MOD -local COL_META = M.COL_META -local COL_NEG = M.COL_NEG +local COL_UNIQUE = "^xAF6025" +local COL_MOD = "^7" +local COL_META = "^8" +local COL_NEG = "^1" +M.COL_META = COL_META -- ───────────────────────────────────────────────────────────────────────────── -- Unique raw text lookup @@ -197,8 +193,6 @@ local function scoreAllocPassives(nodes, allocNodes) return s end -M.scoreAllocPassives = scoreAllocPassives - local function scoreUnallocPassives(nodes, allocNodes) local s = 0 for nodeId, node in pairs(nodes) do @@ -958,23 +952,4 @@ function M.buildJewelTypes() return jewelTypes end -function M.jewelTypeSortOrder(jt) - if jt.name == "The Light of Meaning" then return 10 end - if jt.name == "Might of the Meek" then return 20 end - if jt.name == "Unnatural Instinct" then return 30 end - if jt.name == "Inspired Learning" then return 40 end - if jt.name == "Anatomical Knowledge" then return 50 end - if jt.name == "Tempered & Transcendent" then return 55 end - if jt.name == "Lioneye's Fall" then return 60 end - if jt.name == "Intuitive Leap" then return 70 end - if jt.isImpossibleEscape then return 75 end - if jt.isSplitPersonality then return 80 end - if jt.name == "Stat Conversion" then return 90 end - if jt.name == "Attribute Conversion" then return 100 end - if jt.name == "Combat Focus" then return 110 end - if jt.name == "Dreams & Nightmares" then return 120 end - if jt.isThread then return 130 end - return 1000 -end - return M diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 20773240b3..54492f3d5f 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -102,9 +102,7 @@ local IMPACT_STATS = RadiusJewelData.buildImpactStats() local DISCONNECTED_PASSIVE_COMPUTE_METHODS = RadiusJewelData.DISCONNECTED_PASSIVE_COMPUTE_METHODS local OCCUPIED_SOCKET_OPTIONS = RadiusJewelData.OCCUPIED_SOCKET_OPTIONS local jewelPreviewFn = RadiusJewelData.jewelPreviewFn -local scoreAllocPassives = RadiusJewelData.scoreAllocPassives local buildJewelTypes = RadiusJewelData.buildJewelTypes -local jewelTypeSortOrder = RadiusJewelData.jewelTypeSortOrder local makeVariantDropdownEntry = RadiusJewelData.makeVariantDropdownEntry local findDisconnectedPassiveComputeMethod = RadiusJewelData.findDisconnectedPassiveComputeMethod local getSplitPersonalityVariants = RadiusJewelData.getSplitPersonalityVariants @@ -1375,42 +1373,42 @@ local function buildRadiusJewelPopupContext(self) end local function buildGenericTypeTooltipLinesForJewelType(jewelType) - if not jewelType then - return nil - end - if not (jewelType.isThread or jewelType.variants) then - local lines = buildPreviewLinesForJewelType(jewelType) + if not jewelType then + return nil + end + if not (jewelType.isThread or jewelType.variants) then + local lines = buildPreviewLinesForJewelType(jewelType) + if type(lines) ~= "table" then + return nil + end + return lines + end + local fn = jewelPreviewFn[jewelType.name] + local lines = fn and fn() or nil if type(lines) ~= "table" then return nil end - return lines - end - local fn = jewelPreviewFn[jewelType.name] - local lines = fn and fn() or nil - if type(lines) ~= "table" then - return nil - end - local genericLines = { } - local blankCount = 0 - for _, line in ipairs(lines) do - t_insert(genericLines, line) - if line[1] == "" then - blankCount = blankCount + 1 - if blankCount >= 2 then - break + local genericLines = { } + local blankCount = 0 + for _, line in ipairs(lines) do + t_insert(genericLines, line) + if line[1] == "" then + blankCount = blankCount + 1 + if blankCount >= 2 then + break + end end end + local note + if jewelType.isThread then + note = "Multiple ring sizes available" + else + note = "Multiple variants available" + end + t_insert(genericLines, { height = 16, [1] = COL_META .. note }) + return genericLines end - local note - if jewelType.isThread then - note = "Multiple ring sizes available" - else - note = "Multiple variants available" - end - t_insert(genericLines, { height = 16, [1] = COL_META .. note }) - return genericLines -end local function isAnyFinderDropdownDropped() return (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) or (controls.jewelVariantSelect and controls.jewelVariantSelect.dropped) @@ -1606,18 +1604,18 @@ end addPreviewLines(lines) end - controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, contentTopY, leftPanelWidth, resultListBottomY - contentTopY }, self.build, socketViewer) - controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped - controls.resultsList.OnSelect = function(_, _, row) - updateResultDetails(row) - updatePreview(row) - end - controls.resultsList.OnSelClick = function(_, index, value, doubleClick) - if doubleClick then - applySelectedResult() - end + controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, contentTopY, leftPanelWidth, resultListBottomY - contentTopY }, self.build, socketViewer) + controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped + controls.resultsList.OnSelect = function(_, _, row) + updateResultDetails(row) + updatePreview(row) + end + controls.resultsList.OnSelClick = function(_, index, value, doubleClick) + if doubleClick then + applySelectedResult() end - controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") + end + controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") local function rebuildJewelTypeDropdown() jewelTypes = buildJewelTypes() @@ -1647,29 +1645,32 @@ end end if controls.jewelTypeSelect then controls.jewelTypeSelect:SetList(jtLabels) - -- keep current selection if still visible, else reset to first + -- 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 + 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 + local hasVariants = selectedJewelType.variants ~= nil + controls.jewelVariantLabel.shown = hasVariants + controls.jewelVariantSelect.shown = hasVariants + if hasVariants then + syncDisplayedVariants() + else + selectedJewelVariant = nil + end saveFinderState() else - -- initial build before controls exist + -- Select the initial entry before controls exist. selectedJewelType = activeJewelTypes[1] end end - rebuildJewelTypeDropdown() -- initial build (controls.jewelTypeSelect not yet created) + rebuildJewelTypeDropdown() controls.jewelTypeLabel = new("LabelControl"):LabelControl(TL, { edgePadding, headerLabelY, 0, 16 }, "^7Type:") @@ -1779,151 +1780,151 @@ end 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 }, "^7Preview ring:") - controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx) + -- Thread ring selector (shown when Thread of Hope selected) + controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Preview ring:") + controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx) cancelCompute() selectedThreadVariant = threadVariants[idx] saveFinderState() updatePreview() runFind(false) end) - controls.threadVariantLabel.shown = false - controls.threadVariantSelect.shown = false + 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) - cancelCompute() - selectedVariantGroup = variantGroupOptions[idx] or variantGroupOptions[1] - controls.jewelVariantSelect.selIndex = 1 - selectedJewelVariant = nil - syncDisplayedVariants() + controls.variantGroupLabel = new("LabelControl"):LabelControl(TL, { variantGroupX, headerLabelY, 0, 16 }, "^7Jewel:") + controls.variantGroupSelect = new("DropDownControl"):DropDownControl(TL, { variantGroupX, headerInputY, variantGroupWidth, 20 }, { "All" }, function(idx) + cancelCompute() + selectedVariantGroup = variantGroupOptions[idx] or variantGroupOptions[1] + controls.jewelVariantSelect.selIndex = 1 + selectedJewelVariant = nil + syncDisplayedVariants() + saveFinderState() + updatePreview() + runFind(false) + 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) + cancelCompute() + local variants = getDisplayedVariants() + if variants then + selectedJewelVariant = idx == 1 and nil or variants[idx - 1] saveFinderState() updatePreview() - runFind(false) - 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) - cancelCompute() - local variants = getDisplayedVariants() - if variants then - selectedJewelVariant = idx == 1 and nil or variants[idx - 1] - saveFinderState() - updatePreview() - if controls.findButton then - controls.findButton.shown = not (selectedJewelType and selectedJewelType.variants - and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) - 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 + if controls.findButton then + controls.findButton.shown = not (selectedJewelType and selectedJewelType.variants + and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) 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() - if selectedJewelType.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(DISCONNECTED_PASSIVE_COMPUTE_METHODS) - if controls.computeButton then - controls.computeButton.shown = true - end - if controls.findButton then - controls.findButton.shown = false - end - selectedJewelVariant = nil - return - end - controls.allJewelsViewLabel.shown = false - controls.allJewelsViewSelect.shown = false - local isThread = selectedJewelType.isThread == true - local hasVariants = selectedJewelType.variants ~= nil - local hasVariantGroupFilter = syncVariantGroupSelect() - local hasComputeMethods = selectedJewelSupportsComputeMethods() - syncVariantControlLayout(hasVariantGroupFilter) - - controls.threadVariantLabel.shown = isThread - controls.threadVariantSelect.shown = isThread - 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 = selectedJewelType.hasCompute - controls.impactStatSelect.shown = selectedJewelType.hasCompute - if controls.findButton then - controls.findButton.shown = true - end - if controls.computeButton then - controls.computeButton.shown = selectedJewelType.hasCompute + 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 - if hasVariants then - if not hasVariantGroupFilter then - selectedVariantGroup = variantGroupOptions[1] - controls.variantGroupSelect.selIndex = 1 - end - syncDisplayedVariants() - else - selectedJewelVariant = nil + local function syncSelectedJewelTypeControls() + if selectedJewelType.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(DISCONNECTED_PASSIVE_COMPUTE_METHODS) + if controls.computeButton then + controls.computeButton.shown = true end - if controls.findButton and hasVariants and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape then + if controls.findButton then controls.findButton.shown = false end - if hasComputeMethods then - syncComputeMethodSelect(selectedJewelType.computeMethods) + selectedJewelVariant = nil + return + end + controls.allJewelsViewLabel.shown = false + controls.allJewelsViewSelect.shown = false + local isThread = selectedJewelType.isThread == true + local hasVariants = selectedJewelType.variants ~= nil + local hasVariantGroupFilter = syncVariantGroupSelect() + local hasComputeMethods = selectedJewelSupportsComputeMethods() + syncVariantControlLayout(hasVariantGroupFilter) + + controls.threadVariantLabel.shown = isThread + controls.threadVariantSelect.shown = isThread + 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 = selectedJewelType.hasCompute + controls.impactStatSelect.shown = selectedJewelType.hasCompute + if controls.findButton then + controls.findButton.shown = true + end + if controls.computeButton then + controls.computeButton.shown = selectedJewelType.hasCompute + end + + if hasVariants then + if not hasVariantGroupFilter then + selectedVariantGroup = variantGroupOptions[1] + controls.variantGroupSelect.selIndex = 1 end + syncDisplayedVariants() + else + selectedJewelVariant = nil + end + if controls.findButton and hasVariants and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape then + controls.findButton.shown = false + end + if hasComputeMethods then + syncComputeMethodSelect(selectedJewelType.computeMethods) + end end -- Jewel type dropdown (defined after variant controls so :Click() is safe) From 6d81e4fa68a26829d8d2da2df0d60d886f91856f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 16 Aug 2026 23:14:21 +0200 Subject: [PATCH 30/52] Model canonical radius jewel variants Use unique-item identity for grouped-family limits and source Thread and Massive radii from canonical item and tree data. Addresses PR 10057 L1 remediation. --- manifest.xml | 6 +- spec/System/RadiusJewelFinderTestSupport.lua | 14 +- spec/System/TestRadiusJewelCompute_spec.lua | 37 +++ spec/System/TestRadiusJewelData_spec.lua | 64 ++++- spec/System/TestRadiusJewelFinder_spec.lua | 191 +++++++++++++ src/Classes/RadiusJewelCompute.lua | 47 +-- src/Classes/RadiusJewelData.lua | 68 ++++- src/Classes/RadiusJewelFinder.lua | 284 +++++++++++-------- 8 files changed, 520 insertions(+), 191 deletions(-) diff --git a/manifest.xml b/manifest.xml index 39680d1538..410a2c79e8 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,10 +171,10 @@ - - + + - + diff --git a/spec/System/RadiusJewelFinderTestSupport.lua b/spec/System/RadiusJewelFinderTestSupport.lua index 3e4ee68299..281373cece 100644 --- a/spec/System/RadiusJewelFinderTestSupport.lua +++ b/spec/System/RadiusJewelFinderTestSupport.lua @@ -101,19 +101,7 @@ function support.makeImpossibleEscapeTestVariant() end function support.makeThreadVariants() - local names = { "Small", "Medium", "Large", "Very Large", "Massive" } - local variants = { } - local variantIndex = 1 - for radiusIndex, radius in ipairs(data.jewelRadius) do - if radius.inner > 0 then - variants[#variants + 1] = { - name = names[variantIndex] or ("Ring " .. variantIndex), - radiusIndex = radiusIndex, - } - variantIndex = variantIndex + 1 - end - end - return variants + return support.RadiusJewelData.getThreadOfHopeVariants() end function support.isSorted(results, key) diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 2e1f82bd31..57682596c8 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -1109,6 +1109,43 @@ describe("RadiusJewelCompute #radius-jewel", function() 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" }) diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua index ef28210388..86f9dc87a5 100644 --- a/spec/System/TestRadiusJewelData_spec.lua +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -55,6 +55,49 @@ describe("RadiusJewelData #radius-jewel", function() describe("buildJewelTypes", function() + 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 @@ -269,6 +312,9 @@ describe("RadiusJewelData #radius-jewel", function() 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] @@ -299,17 +345,13 @@ describe("RadiusJewelData #radius-jewel", function() assert.is_not_nil(capturedOptions) assert.is_true(capturedOptions.keystoneOnly) - assert.is_function(capturedOptions.collectNodes) - local massiveRadiusIndex - for index, radius in ipairs(data.jewelRadius) do - if radius.outer > data.jewelRadius[getSmallRadiusIndex()].outer - and radius.outer <= RadiusJewelData.FULL_MASSIVE_RADIUS then - massiveRadiusIndex = index - break - end - end - assert.is_not_nil(massiveRadiusIndex, "expected a radius beyond Small and within Massive Radius") + 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 = { @@ -319,6 +361,8 @@ describe("RadiusJewelData #radius-jewel", function() } local candidates = finder: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() diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 644a262fba..4f1f47cf6e 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -4,6 +4,7 @@ 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() @@ -83,6 +84,196 @@ describe("RadiusJewelFinder #radius-jewel", function() end) describe("popup integration", function() + local previousJewelRadius + local previousMaxJewelRadius + + before_each(function() + previousJewelRadius = data.jewelRadius + previousMaxJewelRadius = data.maxJewelRadius + end) + + after_each(function() + while main.popups[1] do + main:ClosePopup() + end + data.jewelRadius = previousJewelRadius + data.maxJewelRadius = previousMaxJewelRadius + 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 threadVariants = RadiusJewelData.getThreadOfHopeVariants() + local syntheticSocketId = 990011 + local syntheticNotable = { id = 990012, type = "Notable", name = "Synthetic Notable" } + local nodesInRadius = { } + for _, variant in ipairs(threadVariants) do + nodesInRadius[variant.radiusIndex] = { [syntheticNotable.id] = syntheticNotable } + end + build.spec.tree.nodes[syntheticSocketId] = { + id = syntheticSocketId, + nodesInRadius = nodesInRadius, + } + 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 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.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].applyRawText) + popup.controls.resultsList.selIndex = 1 + assert.is_true(popup.controls.applyButton.enabled()) + + finder.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) + return { + { + socket = sockets[1], + variant = 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.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].applyRawText) + popup.controls.resultsList.selIndex = 1 + assert.is_true(popup.controls.applyButton.enabled()) + + end) + + it("isolates grouped limit identities for All variants and All jewels Compute", function() + local sourceSocketId = 36634 + local targetSocketId = 990021 + local equippedItemId = 1990021 + local sourceSlot = build.itemsTab.sockets[sourceSocketId] + assert.is_not_nil(sourceSlot) + build.itemsTab.items[equippedItemId] = { title = "The Red Nightmare", limit = 1 } + sourceSlot.selItemId = equippedItemId + build.spec.jewels[sourceSocketId] = equippedItemId + + 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.computeBestVariantSocketImpact = function(_, sockets, variants) + local identity = variants[1].variantIdentity + local limitKey = identity.limitKey + for _, variant in ipairs(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 = sockets[1], variant = variants[1], delta = sourceDelta, baseOutput = { }, compareOutput = { } }, + { socket = sockets[2], variant = variants[1], delta = targetDelta, baseOutput = { }, compareOutput = { } }, + }, 100 + end + finder.computeSocketImpact = function() return { }, 100 end + finder.computeBestIntuitiveLeapSocketImpact = function() return { }, 100 end + finder.computeThreadOfHopeSocketImpact = function() return { }, 100 end + finder.computeImpossibleEscapeSocketImpact = function() return { }, 100 end + finder.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("keep", 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) diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 6f4dd8ac13..c64b55c46e 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -8,7 +8,7 @@ -- local attachCompute = LoadModule("Classes/RadiusJewelCompute") -- attachCompute(RadiusJewelFinderClass, { -- extractTooltipStats, normalizeImpactStat, calculateImpactPercent, --- mustGetUniqueRawText, buildNodeLabelList, fullMassiveRadius, +-- mustGetUniqueRawText, buildNodeLabelList, getJewelRadiusIndex, -- }) -- local ipairs = ipairs @@ -24,7 +24,7 @@ local normalizeImpactStat = helpers.normalizeImpactStat local calculateImpactPercent = helpers.calculateImpactPercent local mustGetUniqueRawText = helpers.mustGetUniqueRawText local buildNodeLabelList = helpers.buildNodeLabelList -local FULL_MASSIVE_RADIUS = helpers.fullMassiveRadius +local getJewelRadiusIndex = helpers.getJewelRadiusIndex -- ───────────────────────────────────────────────────────────────────────────── -- Local helpers @@ -560,38 +560,12 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) local statField = impactStat.field - local radiusLookup = { } - for i, radius in ipairs(data.jewelRadius) do - if radius.inner == 0 and not radiusLookup[radius.label] then - radiusLookup[radius.label] = i - end - end - local isMassiveRadius = variant and variant.isMassiveRadius local keystoneOnly = variant and variant.keystoneOnly or false local rawText = (variant and variant.rawText) or mustGetUniqueRawText("Intuitive Leap") - - local function collectMassiveNodes(socketNode) - local nodes = { } - if not socketNode or not socketNode.nodesInRadius then - return nodes - end - for idx, radius in ipairs(data.jewelRadius) do - if radius.outer <= FULL_MASSIVE_RADIUS and socketNode.nodesInRadius[idx] then - for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do - nodes[nodeId] = node - end - end - end - return nodes - end - - local candidateOptions = isMassiveRadius and { - collectNodes = collectMassiveNodes, + local candidateOptions = { + radiusIndex = variant and variant.radiusIndex or getJewelRadiusIndex("Small"), keystoneOnly = keystoneOnly, - } or { - radiusIndex = radiusLookup["Small"], - keystoneOnly = false, } local variantKey = variant and variant.name or "normal" @@ -671,11 +645,9 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian local results = { } -- Pre-build items per ring variant (avoid re-creating inside the socket loop) - local threadRawText = mustGetUniqueRawText("Thread of Hope") local threadItems = { } - for variantIndex in ipairs(threadVariants) do - local item = new("Item"):Item("Rarity: Unique\n" .. threadRawText) - item.variant = variantIndex + for variantIndex, threadVariant in ipairs(threadVariants) do + local item = new("Item"):Item("Rarity: Unique\n" .. threadVariant.rawText) item:BuildModList() threadItems[variantIndex] = item end @@ -876,12 +848,7 @@ function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants end local function getSmallRadiusIndex() - for i, radius in ipairs(data.jewelRadius) do - if radius.label == "Small" and radius.inner == 0 then - return i - end - end - return nil + return getJewelRadiusIndex("Small") end local function prepareImpossibleEscapeVariants(self, variants, smallRadiusIndex, notableOrKeystoneOnly) diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index b1c22146c2..69178918d3 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -12,9 +12,6 @@ local s_format = string.format local M = { } --- Outer boundary for the full Massive radius used by the Foulborn Intuitive Leap effect. -M.FULL_MASSIVE_RADIUS = 2400 - -- ───────────────────────────────────────────────────────────────────────────── -- Color constants -- ───────────────────────────────────────────────────────────────────────────── @@ -124,6 +121,40 @@ local function getRadiusIndexFromRawText(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 getUniqueRadiusIndex(name, baseName) return getRadiusIndexFromRawText(mustGetCurrentUniqueRawText(name, baseName)) end @@ -165,6 +196,27 @@ 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$", ""), + 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 -- ───────────────────────────────────────────────────────────────────────────── @@ -312,6 +364,7 @@ local function addIntuitiveLeapFoulbornFields(variant) 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" @@ -854,6 +907,7 @@ function M.buildJewelTypes() 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, { @@ -946,9 +1000,15 @@ function M.buildJewelTypes() scoreLabel = "unalloc notable/keystone in ring", hasCompute = true, computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, - rawText = nil, + rawText = threadOfHopeRawText, score = scoreUnallocNotablesAndKeystones, }) + 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 diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 54492f3d5f..ae97570f1c 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -15,7 +15,7 @@ local m_abs = math.abs local RadiusJewelData = LoadModule("Classes/RadiusJewelData") local COL_META = RadiusJewelData.COL_META -local FULL_MASSIVE_RADIUS = RadiusJewelData.FULL_MASSIVE_RADIUS +local getJewelRadiusIndex = RadiusJewelData.getJewelRadiusIndex -- Small output snapshot for stat-comparison tooltips. -- Copies scalar fields and compact Minion output while skipping nested @@ -221,15 +221,19 @@ 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) +function RadiusJewelFinderClass:findEquippedJewelSockets(jewelType, variant) local equipped = { } - local limit + 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 == jewelType.name then + if itemName == limitKey then limit = limit or item.limit t_insert(equipped, { socketId = socketId, @@ -257,13 +261,7 @@ function RadiusJewelFinderClass:findDisconnectedPassiveDependentNodes(socketId, local radiusNodes = { } if item.jewelData and item.jewelData.impossibleEscapeKeystones then -- IE: nodes in Small radius around each keystone - local smallRI - for i, radius in ipairs(data.jewelRadius) do - if radius.label == "Small" and radius.inner == 0 then - smallRI = i - break - end - end + local smallRI = getJewelRadiusIndex("Small") if smallRI and treeData.keystoneMap then for keystoneName, _ in pairs(item.jewelData.impossibleEscapeKeystones) do local ksNode = treeData.keystoneMap[keystoneName] @@ -392,7 +390,7 @@ local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCo calculateImpactPercent = calculateImpactPercent, mustGetUniqueRawText = mustGetUniqueRawText, buildNodeLabelList = buildNodeLabelList, - fullMassiveRadius = FULL_MASSIVE_RADIUS, + getJewelRadiusIndex = getJewelRadiusIndex, }) -- ───────────────────────────────────────────────────────────────────────────── @@ -474,29 +472,11 @@ end local function buildRadiusJewelPopupSetup(self) local treeData = self.build.spec.tree - 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 - - local threadVariants = { } - local threadRawText = mustGetUniqueRawText("Thread of Hope") - local threadItem = new("Item"):Item("Rarity: Unique\n" .. threadRawText) - local threadVariantIndex = 1 - for i, radius in ipairs(data.jewelRadius) do - if radius.inner > 0 then - local ringName = threadItem.variantList and threadItem.variantList[threadVariantIndex] - if ringName then - ringName = ringName:gsub(" Ring$", "") - else - ringName = "Ring " .. threadVariantIndex - end - t_insert(threadVariants, { name = ringName, radiusIndex = i }) - threadVariantIndex = threadVariantIndex + 1 - end - end + local radiusIndexByLabel = { + Small = getJewelRadiusIndex("Small"), + Large = getJewelRadiusIndex("Large"), + } + local threadVariants = RadiusJewelData.getThreadOfHopeVariants() local threadVariantLabels = { } for _, variant in ipairs(threadVariants) do @@ -606,7 +586,6 @@ local function runRadiusJewelFind(self, context, makePreferred) local isThreadBestVariantSearch = selectedJewelType.isThread == true local isImpossibleEscapeBestVariantSearch = selectedJewelType.isImpossibleEscape == true local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true - local isMassiveRadiusVariant = selectedJewelVariant and selectedJewelVariant.isMassiveRadius local radiusIndex local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil if isThreadBestVariantSearch then @@ -615,8 +594,6 @@ local function runRadiusJewelFind(self, context, makePreferred) end elseif isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then radiusIndex = nil - elseif isMassiveRadiusVariant then - -- data.jewelRadius has no full Massive radius; we handle it below. elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then radiusIndex = selectedJewelVariant.radiusIndex else @@ -624,7 +601,7 @@ local function runRadiusJewelFind(self, context, makePreferred) end if not isThreadBestVariantSearch and not isImpossibleEscapeBestVariantSearch and not isSplitPersonalitySearch - and not radiusIndex and not isMassiveRadiusVariant then + and not radiusIndex then return end @@ -720,20 +697,7 @@ local function runRadiusJewelFind(self, context, makePreferred) storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, }) else - local nodes - if isMassiveRadiusVariant then - -- Merge every parsed ring inside the full Massive boundary. - nodes = { } - for idx, r in ipairs(data.jewelRadius) do - if r.outer <= FULL_MASSIVE_RADIUS and socketNode.nodesInRadius[idx] then - for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do - nodes[nodeId] = node - end - end - end - else - nodes = socketNode.nodesInRadius[radiusIndex] - end + local nodes = socketNode.nodesInRadius[radiusIndex] if nodes then local scoreFn = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.score) @@ -767,7 +731,8 @@ local function runRadiusJewelFind(self, context, makePreferred) t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end) - local equippedList = self:findEquippedJewelSockets(selectedJewelType) + local equippedVariant = selectedJewelVariant or (isThreadBestVariantSearch and selectedThreadVariant or nil) + local equippedList = self:findEquippedJewelSockets(selectedJewelType, equippedVariant) local equippedSocketIds = { } local existingSocketId for _, entry in ipairs(equippedList) do @@ -916,6 +881,57 @@ local function runRadiusJewelCompute(self, context) local statLabel = selectedImpactStat.label local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil) local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil + 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 = self:computeBestVariantSocketImpact( + jewelSockets, partition.variants, selectedImpactStat, + partitionProgress, selectedMaxPoints, selectedOccupiedMode) + 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 selectedJewelType.isAllJewels then local allRows = { } @@ -944,44 +960,49 @@ local function runRadiusJewelCompute(self, context) } end local typeProgress = wrapProgress(rawChild) - local equippedList = self:findEquippedJewelSockets(jt) - local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } - computeState.computeContext.removedJewels = removedJewels local socketResults, baseline - - if jt.name == "Intuitive Leap" then - socketResults, baseline = + local typeRows + local isStandardVariantType = jt.variants and #jt.variants > 0 + and jt.name ~= "Intuitive Leap" + and not jt.isThread + and not jt.isImpossibleEscape + and not jt.isSplitPersonality + + if isStandardVariantType 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 + if jt.name == "Intuitive Leap" then + socketResults, baseline = self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) - elseif jt.isThread then - socketResults, baseline = + elseif jt.isThread then + socketResults, baseline = self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) - elseif jt.isImpossibleEscape then - socketResults, baseline = + elseif jt.isImpossibleEscape then + socketResults, baseline = self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, jt.variants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) - elseif jt.isSplitPersonality then - socketResults, baseline = + elseif jt.isSplitPersonality then + socketResults, baseline = self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, jt.variants or getSplitPersonalityVariants(), typeProgress, selectedMaxPoints, selectedOccupiedMode) - elseif jt.variants and #jt.variants > 0 then - socketResults, baseline = - self:computeBestVariantSocketImpact(jewelSockets, jt.variants, selectedImpactStat, - typeProgress, selectedMaxPoints, selectedOccupiedMode) - else - socketResults, baseline = + else + socketResults, baseline = self:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, typeProgress, selectedMaxPoints, selectedOccupiedMode) + end + self:restoreEquippedJewels(removedJewels) + computeState.computeContext.removedJewels = nil + typeRows = buildComputeRows(jt, socketResults, baseline, equippedList) end globalBaseline = globalBaseline or baseline - self:restoreEquippedJewels(removedJewels) - computeState.computeContext.removedJewels = nil - - local typeRows = buildComputeRows(jt, socketResults, baseline, equippedList) -- For disconnected-passive types: keep only the best row per socket if jt.name == "Intuitive Leap" or jt.isThread or jt.isImpossibleEscape then @@ -1013,37 +1034,51 @@ local function runRadiusJewelCompute(self, context) else local displayedVariants = getSelectedVariants() local itemLabel = selectedJewelType.name - local equippedList = self:findEquippedJewelSockets(selectedJewelType) - local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } - computeState.computeContext.removedJewels = removedJewels local socketResults, baseline - if selectedJewelType.name == "Intuitive Leap" then - socketResults, baseline = - self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, - finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) - elseif selectedJewelType.isThread then - socketResults, baseline = - self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) - elseif selectedJewelType.isImpossibleEscape then - socketResults, baseline = - self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) - elseif selectedJewelType.isSplitPersonality then - socketResults, baseline = - self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) - elseif displayedVariants and #displayedVariants > 0 then + local rows + local useVariantPartitions = displayedVariants and #displayedVariants > 1 + and selectedJewelType.name ~= "Intuitive Leap" + and not selectedJewelType.isThread + and not selectedJewelType.isImpossibleEscape + and not selectedJewelType.isSplitPersonality + if useVariantPartitions then if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then itemLabel = selectedVariantGroup.name end - socketResults, baseline = - self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + rows, baseline = computeVariantPartitionRows(selectedJewelType, displayedVariants, progress) else - local rawText = selectedJewelType.rawText - socketResults, baseline = - self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + 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 selectedJewelType.name == "Intuitive Leap" then + socketResults, baseline = + self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, + finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isThread then + socketResults, baseline = + self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isImpossibleEscape then + socketResults, baseline = + self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + elseif selectedJewelType.isSplitPersonality then + socketResults, baseline = + self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) + elseif displayedVariants and #displayedVariants > 0 then + if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then + itemLabel = selectedVariantGroup.name + end + socketResults, baseline = + self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + else + local rawText = selectedJewelType.rawText + socketResults, baseline = + self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + end + self:restoreEquippedJewels(removedJewels) + computeState.computeContext.removedJewels = nil + rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) end - self:restoreEquippedJewels(removedJewels) - computeState.computeContext.removedJewels = nil - local rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) @@ -2025,35 +2060,42 @@ local function buildRadiusJewelPopupContext(self) return tracker end local function buildComputeRows(jewelType, socketResults, baseline, equippedList) - local equippedSocketIds = { } - local existingSocketId - for _, entry in ipairs(equippedList or { }) do - equippedSocketIds[entry.socketId] = true - if equippedList.atLimit then - existingSocketId = existingSocketId or entry.socketId + 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 - end - -- For limited jewels at capacity, find the keep delta so move rows show the net effect - local keepDelta = 0 - if existingSocketId then - for _, r in ipairs(socketResults) do - if equippedSocketIds[r.socket.id] then - keepDelta = r.delta or 0 - break + -- 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 - end - local rows = { } - for _, r in ipairs(socketResults) do local isEquippedSocket = equippedSocketIds[r.socket.id] local points = isEquippedSocket and 0 or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil }) local variantLabel = r.variant and (r.variant.dropdownLabel or r.variant.name) or "" local itemTooltipLines = buildPreviewLinesForJewelType(jewelType, r.variant) - local applyRawText = r.variant and r.variant.rawText or jewelType.rawText - local jewelLimitKey = applyRawText and applyRawText:match("^([^\n]+)") or jewelType.name + local variantIdentity = r.variant and r.variant.variantIdentity or jewelType.variantIdentity + local applyRawText = variantIdentity and variantIdentity.rawText or r.variant and r.variant.rawText or jewelType.rawText + local jewelLimitKey = variantIdentity and variantIdentity.limitKey + or applyRawText and applyRawText:match("^([^\n]+)") + or jewelType.name jewelLimitKey = jewelLimitKey:gsub("^[Ff]oulborn ", "") - local jewelLimit = jewelType.limit or (applyRawText and tonumber(applyRawText:match("Limited to: (%d+)"))) or nil + local jewelLimit = variantIdentity and variantIdentity.limit + or jewelType.limit + or (applyRawText and tonumber(applyRawText:match("Limited to: (%d+)"))) + or nil local displayedPlans = (jewelType.name == "Intuitive Leap" or jewelType.isThread or jewelType.isImpossibleEscape) and buildDisplayedDisconnectedPassivePlans(r, points, baseline) or { r } From 7c62da7d8866930eedc32d2edd2f6f3b8de2c893 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 09:33:15 +0200 Subject: [PATCH 31/52] Bind radius jewel results to build context Invalidate cached and visible results when the build revision or a result-affecting criterion changes, and prevent stale Apply actions. Addresses PR 10057 L3 remediation. --- manifest.xml | 2 +- spec/System/TestRadiusJewelFinder_spec.lua | 286 +++++++++++++++++++++ src/Classes/RadiusJewelFinder.lua | 272 ++++++++++++++------ 3 files changed, 475 insertions(+), 85 deletions(-) diff --git a/manifest.xml b/manifest.xml index 410a2c79e8..c35dbfcf13 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 4f1f47cf6e..ef0e57d888 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -100,6 +100,65 @@ describe("RadiusJewelFinder #radius-jewel", function() data.maxJewelRadius = previousMaxJewelRadius 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 openResultContextTestPopup(yieldDuringCompute) + build.radiusJewelFinderState = nil + local finder = makeFinder() + local computeCompleted = false + finder.buildJewelSockets = function() + return { { id = 33631, label = "Synthetic socket", pathDist = 1 } } + end + finder.computeBestIntuitiveLeapSocketImpact = function(_, sockets, _, variants, methodId, planCache) + planCache["result-context-test"] = methodId + if yieldDuringCompute then + coroutine.yield() + end + computeCompleted = true + return { + { + socket = sockets[1], + variant = 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 assertCachedResultsAreApplicable(popup, resultContextKey, expectedCount, message) + assert.are.equal(expectedCount or 1, #popup.controls.resultsList.list, message) + assert.are.equal(resultContextKey, popup.controls.resultsList.list[1].resultContextKey, message) + popup.controls.resultsList.selIndex = 1 + assert.is_true(popup.controls.applyButton.enabled(), message) + 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 + it("uses the canonical Massive radius for Foulborn Intuitive Leap Find", function() data.setJewelRadiiGlobally("3_29") local massiveRadiusIndex = RadiusJewelData.getJewelRadiusIndex("Massive") @@ -189,6 +248,233 @@ describe("RadiusJewelFinder #radius-jewel", function() end) + it("clears or restores results for every result-affecting criterion", function() + local _, popup = openResultContextTestPopup() + runPopupCompute(popup) + local resultContextKey = popup.controls.resultsList.list[1].resultContextKey + local intuitiveLeapIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap") + local threadOfHopeIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope") + assert.is_string(resultContextKey) + + local changes = { + { + name = "variant", + change = function() popup.controls.jewelVariantSelect.selFunc(2) end, + restore = function() popup.controls.jewelVariantSelect.selFunc(1) end, + }, + { + name = "impact stat", + change = function() popup.controls.impactStatSelect.selFunc(2) end, + restore = function() popup.controls.impactStatSelect.selFunc(1) end, + }, + { + name = "compute method", + change = function() popup.controls.computeMethodSelect.selFunc(2) end, + restore = function() popup.controls.computeMethodSelect.selFunc(1) end, + }, + { + name = "max points", + change = function() popup.controls.maxPointsEdit:SetText("21", true) end, + restore = function() popup.controls.maxPointsEdit:SetText("20", true) end, + }, + { + name = "occupied sockets", + change = function() popup.controls.occupiedModeSelect.selFunc(2) end, + restore = function() popup.controls.occupiedModeSelect.selFunc(1) end, + }, + { + name = "jewel type", + change = function() popup.controls.jewelTypeSelect.selFunc(threadOfHopeIndex) end, + restore = function() popup.controls.jewelTypeSelect.selFunc(intuitiveLeapIndex) end, + }, + } + for _, criterion in ipairs(changes) do + criterion.change() + assertResultsCleared(popup, criterion.name .. " should clear unmatched results") + criterion.restore() + assertCachedResultsAreApplicable(popup, resultContextKey, 1, + criterion.name .. " should restore matching cached results") + end + 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.computeBestVariantSocketImpact = function(_, sockets, variants) + return { + { + socket = sockets[1], + variant = variants[1], + delta = 1, + baseOutput = { }, + compareOutput = { }, + }, + }, 100 + end + finder.computeSocketImpact = function() return { }, 100 end + finder.computeBestIntuitiveLeapSocketImpact = function() return { }, 100 end + finder.computeThreadOfHopeSocketImpact = function() return { }, 100 end + finder.computeImpossibleEscapeSocketImpact = function() return { }, 100 end + finder.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) + assertResultsCleared(popup) + popup.controls.variantGroupSelect.selFunc(1) + assertCachedResultsAreApplicable(popup, groupedContextKey, groupedResultCount) + + 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.showLegacyCheck.changeFunc(true) + assertResultsCleared(popup) + popup.controls.showLegacyCheck.changeFunc(false) + assertCachedResultsAreApplicable(popup, allJewelsContextKey, allJewelsResultCount) + end) + + it("keeps the Thread preview ring outside Find and Compute result identity", function() + local threadVariants = RadiusJewelData.getThreadOfHopeVariants() + local syntheticSocketId = 990013 + local syntheticNotable = { id = 990014, type = "Notable", name = "Synthetic Notable" } + local nodesInRadius = { } + for _, variant in ipairs(threadVariants) do + nodesInRadius[variant.radiusIndex] = { [syntheticNotable.id] = syntheticNotable } + end + build.spec.tree.nodes[syntheticSocketId] = { + id = syntheticSocketId, + nodesInRadius = nodesInRadius, + } + local finder = makeFinder() + finder.buildJewelSockets = function() + return { { id = syntheticSocketId, label = "Synthetic socket", pathDist = 1 } } + end + finder.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) + return { + { + socket = sockets[1], + variant = 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")) + popup.controls.findButton:Click() + local findRow = popup.controls.resultsList.list[1] + assert.is_not_nil(findRow) + local findResultContextKey = findRow.resultContextKey + popup.controls.resultsList.selIndex = 1 + + popup.controls.threadVariantSelect.selFunc(2) + + assert.are.equal("findThread", popup.controls.resultsList.mode) + assert.are.equal(findRow, popup.controls.resultsList.list[1]) + assert.are.equal(findResultContextKey, popup.controls.resultsList.list[1].resultContextKey) + assert.is_true(popup.controls.applyButton.enabled()) + + runPopupCompute(popup) + local row = popup.controls.resultsList.list[1] + assert.is_not_nil(row) + local resultContextKey = row.resultContextKey + + popup.controls.threadVariantSelect.selFunc(1) + + assert.are.equal("computeSocket", popup.controls.resultsList.mode) + assert.are.equal(row, popup.controls.resultsList.list[1]) + assert.are.equal(resultContextKey, popup.controls.resultsList.list[1].resultContextKey) + assert.is_true(popup.controls.applyButton.enabled()) + assert.are.equal(threadVariants[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) + assert.is_nil(next(build.radiusJewelFinderState.computeCache)) + assert.is_nil(next(build.radiusJewelFinderState.resultViewByKey)) + end) + + it("cancels a suspended Compute before stale revision results can be saved", function() + local _, popup, computeCompleted = openResultContextTestPopup(true) + popup.controls.computeButton:Click() + runCallback("OnFrame") + assert.is_not_nil(main.onFrameFuncs["RadiusJewelFinderCompute"]) + assert.is_not_nil(next(build.radiusJewelFinderState.disconnectedPassivePlanCache)) + + build.outputRevision = build.outputRevision + 1 + runCallback("OnFrame") + + assert.is_nil(main.onFrameFuncs["RadiusJewelFinderCompute"]) + assert.is_false(computeCompleted()) + assertResultsCleared(popup) + assert.is_nil(next(build.radiusJewelFinderState.findCache)) + assert.is_nil(next(build.radiusJewelFinderState.computeCache)) + assert.is_nil(next(build.radiusJewelFinderState.resultViewByKey)) + assert.is_nil(next(build.radiusJewelFinderState.disconnectedPassivePlanCache)) + end) + + it("restores matching results after closing and reopening without a build mutation", function() + local finder, popup = openResultContextTestPopup() + runPopupCompute(popup) + local resultContextKey = popup.controls.resultsList.list[1].resultContextKey + assertCachedResultsAreApplicable(popup, resultContextKey) + + popup.controls.closeButton:Click() + local reopenedPopup = finder:Open() + + assertCachedResultsAreApplicable(reopenedPopup, resultContextKey) + end) + + it("invalidates every cache and 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 + local finderState = build.radiusJewelFinderState + finderState.findCache["old-find"] = { } + assert.is_not_nil(next(finderState.findCache)) + assert.is_not_nil(next(finderState.computeCache)) + assert.is_not_nil(next(finderState.resultViewByKey)) + assert.is_not_nil(next(finderState.disconnectedPassivePlanCache)) + + 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) + assert.is_nil(next(finderState.findCache)) + assert.is_nil(next(finderState.computeCache)) + assert.is_nil(next(finderState.resultViewByKey)) + assert.is_nil(next(finderState.disconnectedPassivePlanCache)) + end) + it("isolates grouped limit identities for All variants and All jewels Compute", function() local sourceSocketId = 36634 local targetSocketId = 990021 diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index ae97570f1c..54e9b05c63 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -470,6 +470,22 @@ end -- Open popup -- ───────────────────────────────────────────────────────────────────────────── +local function synchronizeResultCaches(build, finderState) + local outputRevision = build.outputRevision or 0 + if finderState.resultCacheOutputRevision ~= outputRevision then + finderState.findCache = { } + finderState.computeCache = { } + finderState.resultViewByKey = { } + finderState.disconnectedPassivePlanCache = { } + finderState.resultCacheOutputRevision = outputRevision + else + finderState.findCache = finderState.findCache or { } + finderState.computeCache = finderState.computeCache or { } + finderState.resultViewByKey = finderState.resultViewByKey or { } + finderState.disconnectedPassivePlanCache = finderState.disconnectedPassivePlanCache or { } + end +end + local function buildRadiusJewelPopupSetup(self) local treeData = self.build.spec.tree local radiusIndexByLabel = { @@ -493,10 +509,7 @@ local function buildRadiusJewelPopupSetup(self) local finderState = self.build.radiusJewelFinderState or { } self.build.radiusJewelFinderState = finderState - finderState.findCache = finderState.findCache or { } - finderState.computeCache = finderState.computeCache or { } - finderState.resultViewByKey = finderState.resultViewByKey or { } - finderState.disconnectedPassivePlanCache = finderState.disconnectedPassivePlanCache or { } + synchronizeResultCaches(self.build, finderState) local allJewelsViewOptions = { { id = "all", label = "All results" }, @@ -564,13 +577,14 @@ local function runRadiusJewelFind(self, context, makePreferred) local threadVariants = context.threadVariants local jewelSockets = context.jewelSockets local selectedJewelType = context.selectedJewelType - local selectedThreadVariant = context.selectedThreadVariant local selectedJewelVariant = context.selectedJewelVariant local selectedOccupiedMode = context.selectedOccupiedMode + local resultContextKey = context.resultContextKey local getSelectedVariants = context.getSelectedVariants local formatElapsed = context.formatElapsed local restoreCachedResults = context.restoreCachedResults local saveResultCache = context.saveResultCache + local stampResultRows = context.stampResultRows local showAllJewelsComputePrompt = context.showAllJewelsComputePrompt local searchStartTime = GetTime() @@ -588,11 +602,7 @@ local function runRadiusJewelFind(self, context, makePreferred) local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true local radiusIndex local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil - if isThreadBestVariantSearch then - if selectedThreadVariant then - radiusIndex = selectedThreadVariant.radiusIndex - end - elseif isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then + if isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then radiusIndex = nil elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then radiusIndex = selectedJewelVariant.radiusIndex @@ -731,7 +741,7 @@ local function runRadiusJewelFind(self, context, makePreferred) t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end) - local equippedVariant = selectedJewelVariant or (isThreadBestVariantSearch and selectedThreadVariant or nil) + local equippedVariant = selectedJewelVariant local equippedList = self:findEquippedJewelSockets(selectedJewelType, equippedVariant) local equippedSocketIds = { } local existingSocketId @@ -799,6 +809,7 @@ local function runRadiusJewelFind(self, context, makePreferred) or selectedJewelType.rawText, }) end + stampResultRows(rows, resultContextKey) controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)") local elapsed = formatElapsed(searchStartTime) controls.statusLabel.label = (isThreadBestVariantSearch @@ -808,7 +819,7 @@ local function runRadiusJewelFind(self, context, makePreferred) or isSplitPersonalitySearch and s_format("^7Split Personality | %d | score/pt", #results) or s_format("^7%d results | score/pt", #results)) .. elapsed - saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred) + saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred, resultContextKey) if not makePreferred then restoreCachedResults() end @@ -821,8 +832,8 @@ local function runRadiusJewelFind(self, context, makePreferred) end end -local function applyRadiusJewelResult(self, row) - if not row or not row.applyRawText then +local function applyRadiusJewelResult(self, row, resultContextKey) + if not row or not row.applyRawText or row.resultContextKey ~= resultContextKey then return end @@ -860,10 +871,12 @@ local function runRadiusJewelCompute(self, context) local formatComputeStatus = context.formatComputeStatus local formatElapsed = context.formatElapsed local saveResultCache = context.saveResultCache + local stampResultRows = context.stampResultRows 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") @@ -876,6 +889,7 @@ local function runRadiusJewelCompute(self, context) setComputeProgress("^7Computing...") local progress = makeComputeProgressTracker() computeState.computeContext = { + resultContextKey = resultContextKey, co = coroutine.create(function() local ok, err = pcall(function() local statLabel = selectedImpactStat.label @@ -1025,12 +1039,14 @@ local function runRadiusJewelCompute(self, context) end globalBaseline = globalBaseline or 0 + stampResultRows(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) - saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) + saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true, resultContextKey) else local displayedVariants = getSelectedVariants() local itemLabel = selectedJewelType.name @@ -1079,9 +1095,10 @@ local function runRadiusJewelCompute(self, context) computeState.computeContext.removedJewels = nil rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) end + stampResultRows(rows, resultContextKey) controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) - saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true) + saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true, resultContextKey) end end) if not ok then @@ -1094,6 +1111,11 @@ local function runRadiusJewelCompute(self, context) 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() @@ -1199,24 +1221,38 @@ local function buildRadiusJewelPopupContext(self) finderState.allJewelsViewId = selectedAllJewelsView and selectedAllJewelsView.id or nil end - local function getSelectionKey() - local supportsComputeMethods = selectedJewelType and selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0 + local function getResultContextKey() + synchronizeResultCaches(self.build, finderState) + 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 = selectedJewelType and (selectedJewelType.isAllJewels + or selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0) local computeMethodKey = supportsComputeMethods and selectedComputeMethod and selectedComputeMethod.id or "" + local legacyKey = selectedJewelType and selectedJewelType.isAllJewels and showLegacy and "1" or "0" return table.concat({ - tostring(showLegacy and 1 or 0), + tostring(self.build.outputRevision or 0), selectedJewelType and selectedJewelType.name or "", - selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or "", - selectedThreadVariant and selectedThreadVariant.name or "", - selectedVariantGroup and selectedVariantGroup.value or "", + selectedVariantKey, + variantGroupKey, selectedImpactStat and selectedImpactStat.field or "", computeMethodKey, selectedMaxPoints and tostring(selectedMaxPoints) or "", selectedOccupiedMode and selectedOccupiedMode.id or "", + legacyKey, }, "|") end - local function restoreCachedResults() - local key = getSelectionKey() + local function stampResultRows(rows, resultContextKey) + for _, row in ipairs(rows or { }) do + row.resultContextKey = resultContextKey + end + end + + local function restoreCachedResults(resultContextKey) + local key = resultContextKey or getResultContextKey() local preferredView = finderState.resultViewByKey[key] local allowFindCache = not (selectedJewelType and selectedJewelType.isAllJewels) local findCache = allowFindCache and finderState.findCache[key] or nil @@ -1233,29 +1269,84 @@ local function buildRadiusJewelPopupContext(self) if not cache then return false end + if cache.resultContextKey ~= key then + return false + end local rows = copyTableSafe(cache.rows, false, true) if cache.mode == "computeSocketAll" then computeState.lastComputeAllRows = rows + computeState.lastComputeAllResultContextKey = key if selectedAllJewelsView.id == "bestPerSocket" then rows = self:filterBestPerSocket(rows) end + else + computeState.lastComputeAllRows = nil + computeState.lastComputeAllResultContextKey = nil end controls.resultsList:SetMode(cache.mode, rows, cache.defaultText) controls.statusLabel.label = cache.statusLabel or controls.statusLabel.label return true end - local function saveResultCache(viewName, mode, rows, defaultText, statusLabel, makePreferred) - local key = getSelectionKey() + local function saveResultCache(viewName, mode, rows, defaultText, statusLabel, makePreferred, resultContextKey) + local key = resultContextKey or getResultContextKey() + if key ~= getResultContextKey() then + return false + end local targetCache = viewName == "compute" and finderState.computeCache or finderState.findCache targetCache[key] = { mode = mode, rows = copyTableSafe(rows, false, true), defaultText = defaultText, statusLabel = statusLabel, + resultContextKey = key, } if makePreferred then finderState.resultViewByKey[key] = viewName end + return true + end + local function clearResultsForContext() + computeState.lastComputeAllRows = nil + computeState.lastComputeAllResultContextKey = nil + local message = selectedJewelType and selectedJewelType.isAllJewels + and (COL_META .. "Click Compute to rank all jewels") + or (COL_META .. "Click Find to search") + controls.statusLabel.label = message + controls.resultsList:SetMode("message", { }, message) + end + local function saveVisibleResultView(resultContextKey) + local mode = controls.resultsList.mode + local viewName = (mode == "find" or mode == "findThread") and "find" + or (mode == "computeSocket" or mode == "computeSocketAll") and "compute" + if not viewName then + return + end + local cache + if viewName == "compute" then + cache = finderState.computeCache[resultContextKey] + else + cache = finderState.findCache[resultContextKey] + end + if cache and cache.resultContextKey == resultContextKey then + finderState.resultViewByKey[resultContextKey] = viewName + end + end + local function isResultContextCurrent(resultContextKey) + return resultContextKey == getResultContextKey() + end + local function isResultApplicable(row) + return row ~= nil and row.applyRawText ~= nil and isResultContextCurrent(row.resultContextKey) + end + local function onCriteriaChanged(updateCriteria) + cancelCompute() + local previousResultContextKey = getResultContextKey() + saveVisibleResultView(previousResultContextKey) + updateCriteria() + saveFinderState() + local resultContextKey = getResultContextKey() + if not restoreCachedResults(resultContextKey) then + clearResultsForContext() + end end local function formatComputeStatus(itemLabel, statLabel, baseline, methodLabel) if methodLabel and methodLabel ~= "" then @@ -1711,12 +1802,12 @@ local function buildRadiusJewelPopupContext(self) controls.computeMethodLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, headerLabelY, 0, 16 }, "^7Method:") controls.computeMethodSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX, headerInputY, 160, buttonHeight }, { }, function(idx) - cancelCompute() - local methods = getSelectedComputeMethods() - if methods then - selectedComputeMethod = methods[idx] - end - saveFinderState() + onCriteriaChanged(function() + local methods = getSelectedComputeMethods() + if methods then + selectedComputeMethod = methods[idx] + end + end) end) local function addComputeMethodTooltip(tooltip, mode, index) local methods = getSelectedComputeMethods() @@ -1741,18 +1832,18 @@ local function buildRadiusJewelPopupContext(self) -- Impact stat selector (shown when jewel has compute) 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) - cancelCompute() - selectedImpactStat = IMPACT_STATS[idx] - saveFinderState() + 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) - cancelCompute() - selectedMaxPoints = buf ~= "" and tonumber(buf) or nil - saveFinderState() + onCriteriaChanged(function() + selectedMaxPoints = buf ~= "" and tonumber(buf) or nil + end) end) local function addMaxPointsTooltip(tooltip) tooltip:Clear(true) @@ -1766,10 +1857,9 @@ local function buildRadiusJewelPopupContext(self) 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) - cancelCompute() - selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[idx] - saveFinderState() - runFind(false) + 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 @@ -1793,10 +1883,13 @@ local function buildRadiusJewelPopupContext(self) 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 then + 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)") + elseif computeState.lastComputeAllRows then + clearResultsForContext() end saveFinderState() end) @@ -1818,25 +1911,22 @@ local function buildRadiusJewelPopupContext(self) -- Thread ring selector (shown when Thread of Hope selected) controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Preview ring:") controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx) - cancelCompute() selectedThreadVariant = threadVariants[idx] saveFinderState() updatePreview() - runFind(false) 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) - cancelCompute() - selectedVariantGroup = variantGroupOptions[idx] or variantGroupOptions[1] - controls.jewelVariantSelect.selIndex = 1 - selectedJewelVariant = nil - syncDisplayedVariants() - saveFinderState() - updatePreview() - runFind(false) + onCriteriaChanged(function() + selectedVariantGroup = variantGroupOptions[idx] or variantGroupOptions[1] + controls.jewelVariantSelect.selIndex = 1 + selectedJewelVariant = nil + syncDisplayedVariants() + updatePreview() + end) end) controls.variantGroupLabel.shown = false controls.variantGroupSelect.shown = false @@ -1844,17 +1934,17 @@ local function buildRadiusJewelPopupContext(self) -- 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) - cancelCompute() - local variants = getDisplayedVariants() - if variants then - selectedJewelVariant = idx == 1 and nil or variants[idx - 1] - saveFinderState() - updatePreview() - if controls.findButton then - controls.findButton.shown = not (selectedJewelType and selectedJewelType.variants - and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) + onCriteriaChanged(function() + local variants = getDisplayedVariants() + if variants then + selectedJewelVariant = idx == 1 and nil or variants[idx - 1] + updatePreview() + if controls.findButton then + controls.findButton.shown = not (selectedJewelType and selectedJewelType.variants + and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) + end end - end + end) end) controls.jewelVariantSelect.enableDroppedWidth = true controls.jewelVariantSelect.maxDroppedWidth = 520 @@ -1964,13 +2054,12 @@ local function buildRadiusJewelPopupContext(self) -- Jewel type dropdown (defined after variant controls so :Click() is safe) controls.jewelTypeSelect = new("DropDownControl"):DropDownControl(TL, { 10, headerInputY, 260, 20 }, jtLabels, function(idx) - cancelCompute() - selectedJewelType = activeJewelTypes[idx] - controls.jewelVariantSelect.selIndex = 1 - syncSelectedJewelTypeControls() - saveFinderState() - updatePreview() - runFind(false) + onCriteriaChanged(function() + selectedJewelType = activeJewelTypes[idx] + controls.jewelVariantSelect.selIndex = 1 + syncSelectedJewelTypeControls() + updatePreview() + end) end) controls.jewelTypeSelect.tooltipFunc = function(tooltip, mode, index) local jewelType = activeJewelTypes[index] @@ -2181,6 +2270,7 @@ local function buildRadiusJewelPopupContext(self) end controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, headerInputY, 72, buttonHeight }, "Compute", function() + local resultContextKey = getResultContextKey() runRadiusJewelCompute(self, { controls = controls, computeState = computeState, @@ -2203,10 +2293,14 @@ local function buildRadiusJewelPopupContext(self) formatComputeStatus = formatComputeStatus, formatElapsed = formatElapsed, saveResultCache = saveResultCache, + stampResultRows = stampResultRows, getSelectedVariants = getSelectedVariants, hasVariantGroups = hasVariantGroups, selectedVariantGroup = selectedVariantGroup, allVariantGroupsValue = ALL_VARIANT_GROUPS_VALUE, + resultContextKey = resultContextKey, + isResultContextCurrent = isResultContextCurrent, + clearResultsForContext = clearResultsForContext, }) end) controls.computeButton.tooltipFunc = function(tooltip) @@ -2231,16 +2325,16 @@ local function buildRadiusJewelPopupContext(self) controls.resultsList:SetMode("message", { }, COL_META .. "Click Compute to rank all jewels") end controls.showLegacyCheck = new("CheckBoxControl"):CheckBoxControl(TL, { 700, statusLabelY, 18 }, "Show legacy", function(state) - cancelCompute() - showLegacy = state - saveFinderState() - rebuildJewelTypeDropdown() - syncSelectedJewelTypeControls() - updatePreview() - runFind(false) + onCriteriaChanged(function() + showLegacy = state + rebuildJewelTypeDropdown() + syncSelectedJewelTypeControls() + updatePreview() + end) end) runFind = function(makePreferred) + local resultContextKey = getResultContextKey() runRadiusJewelFind(self, { controls = controls, treeData = treeData, @@ -2248,13 +2342,14 @@ local function buildRadiusJewelPopupContext(self) threadVariants = threadVariants, jewelSockets = jewelSockets, selectedJewelType = selectedJewelType, - selectedThreadVariant = selectedThreadVariant, selectedJewelVariant = selectedJewelVariant, selectedOccupiedMode = selectedOccupiedMode, + resultContextKey = resultContextKey, getSelectedVariants = getSelectedVariants, formatElapsed = formatElapsed, restoreCachedResults = restoreCachedResults, saveResultCache = saveResultCache, + stampResultRows = stampResultRows, showAllJewelsComputePrompt = showAllJewelsComputePrompt, }, makePreferred) end @@ -2272,16 +2367,25 @@ local function buildRadiusJewelPopupContext(self) applySelectedResult = function() local idx = controls.resultsList.selIndex local row = idx and controls.resultsList.list[idx] - applyRadiusJewelResult(self, row) + local resultContextKey = getResultContextKey() + if isResultApplicable(row) then + applyRadiusJewelResult(self, row, resultContextKey) + end end controls.applyButton = new("ButtonControl"):ButtonControl(BL, { edgePadding + 480, bottomButtonY, 80, buttonHeight }, "Apply", applySelectedResult) controls.applyButton.enabled = function() local idx = controls.resultsList.selIndex - return idx and controls.resultsList.list[idx] and controls.resultsList.list[idx].applyRawText ~= nil + return isResultApplicable(idx and controls.resultsList.list[idx]) end controls.applyButton.tooltipFunc = function(tooltip) local idx = controls.resultsList.selIndex local row = idx and controls.resultsList.list[idx] + if row and row.applyRawText and not isResultApplicable(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.applyRawText then tooltip:Clear(true) tooltip:AddLine(16, "^7Select a result to apply.") @@ -2299,9 +2403,7 @@ local function buildRadiusJewelPopupContext(self) local function restoreFinderState() if not finderState.jewelTypeName then updatePreview() - if selectedJewelType and selectedJewelType.isAllJewels then - showAllJewelsComputePrompt() - end + clearResultsForContext() return end suppressFinderStateSave = true @@ -2398,7 +2500,9 @@ local function buildRadiusJewelPopupContext(self) suppressFinderStateSave = false saveFinderState() updatePreview() - runFind(false) + if not restoreCachedResults() then + clearResultsForContext() + end end controls.closeButton = new("ButtonControl"):ButtonControl(BR, { -edgePadding, bottomButtonY, 100, buttonHeight }, "Close", function() From af7c3b0026c8fb5a8dbb9a4fb5cd11c4cec9b1db Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 17:49:09 +0200 Subject: [PATCH 32/52] Execute radius jewel result actions safely Model each result as an explicit placement plan so Find and Compute agree on Equip, Move, Replace, and Equipped. Add a non-equipping path and preserve exact item/socket state through Items undo without applying passive recommendations. Addresses PR 10057 remediation L2. --- manifest.xml | 4 +- spec/System/RadiusJewelFinderTestSupport.lua | 11 +- spec/System/TestRadiusJewelActions_spec.lua | 736 ++++++++++++++++++ spec/System/TestRadiusJewelFinder_spec.lua | 78 +- src/Classes/RadiusJewelFinder.lua | 588 ++++++++++++-- src/Classes/RadiusJewelResultsListControl.lua | 9 +- 6 files changed, 1312 insertions(+), 114 deletions(-) create mode 100644 spec/System/TestRadiusJewelActions_spec.lua diff --git a/manifest.xml b/manifest.xml index c35dbfcf13..3d1112a481 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,8 +174,8 @@ - - + + diff --git a/spec/System/RadiusJewelFinderTestSupport.lua b/spec/System/RadiusJewelFinderTestSupport.lua index 281373cece..cb62cb78b6 100644 --- a/spec/System/RadiusJewelFinderTestSupport.lua +++ b/spec/System/RadiusJewelFinderTestSupport.lua @@ -125,14 +125,22 @@ function support.snapshotFinderState() end local itemCount = 0 - for _ in pairs(build.itemsTab.items) do + 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 @@ -142,6 +150,7 @@ function support.assertFinderStateUnchanged(before, check) 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 diff --git a/spec/System/TestRadiusJewelActions_spec.lua b/spec/System/TestRadiusJewelActions_spec.lua new file mode 100644 index 0000000000..998d37123b --- /dev/null +++ b/spec/System/TestRadiusJewelActions_spec.lua @@ -0,0 +1,736 @@ +-- 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.computeThreadOfHopeSocketImpact = function(_, sockets) + return { + { + socket = 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.computeSocketImpact = function(_, sockets) + return { + { + socket = 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.computeBestVariantSocketImpact = function(_, sockets) + return { + { + socket = 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:buildActionPlan({ + 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:executeActionPlan(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:buildActionPlan({ + socketId = targetSocketId, + socketLabel = "Free target", + targetIdentity = jewelType.variantIdentity, + targetRawText = jewelType.rawText, + }) + + assert.is_false(plan.targetSocketAllocated) + assert.is_true(finder: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:buildActionPlan({ + socketId = targetSocketId, + socketLabel = "Free target", + targetIdentity = jewelType.variantIdentity, + targetRawText = jewelType.rawText, + }) + + assert.are.equal(existingItem.id, plan.sourceItemId) + assert.is_false(finder: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:buildActionPlan({ + 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: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("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("This socket is unallocated", 1, true) ~= nil) + assert.is_true(details:find("hidden from the Items panel", 1, true) ~= nil) + 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:buildActionPlan({ + 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:executeActionPlan(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:buildActionPlan({ + 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:executeActionPlan(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) + 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("Source:", 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("Source:", 1, true) ~= nil) + assert.is_true(details:find("Will replace:", 1, true) ~= nil) + assert.is_true(details:find("not applied automatically", 1, true) ~= nil) + + 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:buildActionPlan({ + 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:executeActionPlan(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:buildActionPlan({ + socketId = sourceSocketId, + socketLabel = "Unallocated source", + targetIdentity = variant.variantIdentity, + targetRawText = variant.rawText, + }) + + assert.are.equal("equip", equipPlan.kind) + assert.is_true(finder:executeActionPlan(equipPlan)) + local itemId = build.itemsTab.sockets[sourceSocketId].selItemId + assert.is_true(itemId ~= 0) + local movePlan = finder:buildActionPlan({ + socketId = targetSocketId, + socketLabel = "Unallocated destination", + targetIdentity = variant.variantIdentity, + targetRawText = variant.rawText, + }) + + assert.are.equal("move", movePlan.kind) + assert.is_true(finder:executeActionPlan(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:buildActionPlan({ + 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:executeActionPlan(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) + 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:buildActionPlan({ + 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:executeActionPlan(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:buildActionPlan({ + 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:executeActionPlan(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/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index ef0e57d888..ad306617f9 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -193,20 +193,10 @@ describe("RadiusJewelFinder #radius-jewel", function() end) it("enables Apply for Thread of Hope Find and Compute results", function() - local threadVariants = RadiusJewelData.getThreadOfHopeVariants() - local syntheticSocketId = 990011 - local syntheticNotable = { id = 990012, type = "Notable", name = "Synthetic Notable" } - local nodesInRadius = { } - for _, variant in ipairs(threadVariants) do - nodesInRadius[variant.radiusIndex] = { [syntheticNotable.id] = syntheticNotable } - end - build.spec.tree.nodes[syntheticSocketId] = { - id = syntheticSocketId, - nodesInRadius = nodesInRadius, - } + local targetSocketId = 33631 local finder = makeFinder() finder.buildJewelSockets = function() - return { { id = syntheticSocketId, label = "Synthetic socket", pathDist = 1 } } + return { { id = targetSocketId, label = "Target socket", pathDist = 1 } } end local popup = finder:Open() local function findIndex(list, needle) @@ -222,6 +212,8 @@ describe("RadiusJewelFinder #radius-jewel", function() popup.controls.findButton:Click() assert.are.equal(1, #popup.controls.resultsList.list) assert.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].applyRawText) + assert.is_not_nil(popup.controls.resultsList.list[1].actionPlan, + "Find rows should consume the shared action planner") popup.controls.resultsList.selIndex = 1 assert.is_true(popup.controls.applyButton.enabled()) @@ -243,6 +235,8 @@ describe("RadiusJewelFinder #radius-jewel", function() end assert.are.equal(1, #popup.controls.resultsList.list) assert.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].applyRawText) + assert.is_not_nil(popup.controls.resultsList.list[1].actionPlan, + "Compute rows should consume the shared action planner") popup.controls.resultsList.selIndex = 1 assert.is_true(popup.controls.applyButton.enabled()) @@ -345,19 +339,10 @@ describe("RadiusJewelFinder #radius-jewel", function() it("keeps the Thread preview ring outside Find and Compute result identity", function() local threadVariants = RadiusJewelData.getThreadOfHopeVariants() - local syntheticSocketId = 990013 - local syntheticNotable = { id = 990014, type = "Notable", name = "Synthetic Notable" } - local nodesInRadius = { } - for _, variant in ipairs(threadVariants) do - nodesInRadius[variant.radiusIndex] = { [syntheticNotable.id] = syntheticNotable } - end - build.spec.tree.nodes[syntheticSocketId] = { - id = syntheticSocketId, - nodesInRadius = nodesInRadius, - } + local targetSocketId = 33631 local finder = makeFinder() finder.buildJewelSockets = function() - return { { id = syntheticSocketId, label = "Synthetic socket", pathDist = 1 } } + return { { id = targetSocketId, label = "Target socket", pathDist = 1 } } end finder.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) return { @@ -477,13 +462,30 @@ describe("RadiusJewelFinder #radius-jewel", function() it("isolates grouped limit identities for All variants and All jewels Compute", function() local sourceSocketId = 36634 - local targetSocketId = 990021 - local equippedItemId = 1990021 + local targetSocketId = 61419 local sourceSlot = build.itemsTab.sockets[sourceSocketId] + local targetSlot = build.itemsTab.sockets[targetSocketId] assert.is_not_nil(sourceSlot) - build.itemsTab.items[equippedItemId] = { title = "The Red Nightmare", limit = 1 } - sourceSlot.selItemId = equippedItemId - build.spec.jewels[sourceSocketId] = equippedItemId + 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() @@ -549,7 +551,7 @@ describe("RadiusJewelFinder #radius-jewel", function() for _, row in ipairs(popup.controls.resultsList.list) do rowsBySocket[row.socketId] = row end - assert.are.equal("keep", rowsBySocket[sourceSocketId].action) + 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) @@ -634,17 +636,27 @@ describe("RadiusJewelFinder #radius-jewel", function() "previewList", "resultDetailList", "findButton", + "addToBuildButton", "applyButton", "closeButton", }) do assertControlInsidePopup(controlName) end - for _, controlName in ipairs({ "findButton", "applyButton", "closeButton" }) do + 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") @@ -658,6 +670,10 @@ describe("RadiusJewelFinder #radius-jewel", function() 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, @@ -758,7 +774,7 @@ describe("RadiusJewelFinder #radius-jewel", function() sortValue = 10, detailText = "Test detail", itemTooltipLines = selectedResultPreview, - action = "new", + action = "equip", }, }, "(no compatible sockets)") assert.are.equal("^7Selected Jewel", popup.controls.previewList.list[1][1]) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 54e9b05c63..e70f7ed49d 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -248,6 +248,349 @@ function RadiusJewelFinderClass:findEquippedJewelSockets(jewelType, variant) return equipped end +---@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? + +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 + +local function 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 + +local function findCanonicalBuildItem(itemsTab, targetCanonicalKey) + 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 + +local function findExactStoredSource(itemsTab, allocNodes, targetCanonicalKey, targetSocketId) + 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 RadiusJewelFinderClass:buildActionPlan(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 getSocketLabel(targetSocket, target.socketId) + local targetSocketAllocated = self.build.spec.allocNodes[target.socketId] ~= nil + local _, _, _, matchingItemsStateKey = findCanonicalBuildItem(self.build.itemsTab, 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: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 = findExactStoredSource( + self.build.itemsTab, self.build.spec.allocNodes, 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 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 + +local function isActionPlanCurrent(build, plan) + local itemsTab = build.itemsTab + local targetSocket = plan and itemsTab.sockets[plan.targetSocketId] + if not targetSocket or targetSocket.selItemId ~= plan.targetItemId then + return false + end + if (build.spec.allocNodes[plan.targetSocketId] ~= nil) ~= plan.targetSocketAllocated then + return false + end + local _, _, _, matchingItemsStateKey = findCanonicalBuildItem(itemsTab, 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 RadiusJewelFinderClass:executeActionPlan(plan) + local itemsTab = self.build.itemsTab + if not isActionPlanCurrent(self.build, 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 RadiusJewelFinderClass:executeAddToBuildPlan(plan) + local itemsTab = self.build.itemsTab + if not isActionPlanCurrent(self.build, plan) then + return false + end + local existingItem = findCanonicalBuildItem(itemsTab, 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 + -- 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. @@ -744,12 +1087,8 @@ local function runRadiusJewelFind(self, context, makePreferred) local equippedVariant = selectedJewelVariant local equippedList = self:findEquippedJewelSockets(selectedJewelType, equippedVariant) local equippedSocketIds = { } - local existingSocketId for _, entry in ipairs(equippedList) do equippedSocketIds[entry.socketId] = true - if equippedList.atLimit then - existingSocketId = existingSocketId or entry.socketId - end end local rows = { } for _, r in ipairs(results) do @@ -777,18 +1116,19 @@ local function runRadiusJewelFind(self, context, makePreferred) local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] detailNodeId = keystoneNode and keystoneNode.id or nil end - local action - if isEquippedSocket then - action = "keep" - elseif existingSocketId and r.replacedItemLabel then - action = "moveReplace" - elseif existingSocketId then - action = "move" - elseif r.replacedItemLabel then - action = "replace" - else - action = "new" - end + 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:buildActionPlan({ + socketId = r.socket.id, + socketLabel = r.socket.label, + targetIdentity = targetIdentity, + targetRawText = targetRawText, + }) t_insert(rows, { socketLabel = r.socket.label, socketId = r.socket.id, @@ -803,10 +1143,10 @@ local function runRadiusJewelFind(self, context, makePreferred) topNodes = copyTableSafe(r.topNodes, false, true), replacedItemLabel = r.replacedItemLabel, storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, - action = action, - applyRawText = (r.variant and r.variant.rawText) - or (selectedJewelVariant and selectedJewelVariant.rawText) - or selectedJewelType.rawText, + action = actionPlan and actionPlan.kind or nil, + actionPlan = actionPlan, + targetIdentity = targetIdentity, + applyRawText = targetRawText, }) end stampResultRows(rows, resultContextKey) @@ -833,20 +1173,10 @@ local function runRadiusJewelFind(self, context, makePreferred) end local function applyRadiusJewelResult(self, row, resultContextKey) - if not row or not row.applyRawText or row.resultContextKey ~= resultContextKey then + if not row or not row.actionPlan or row.resultContextKey ~= resultContextKey then return end - - local item = new("Item"):Item("Rarity: Unique\n" .. row.applyRawText) - item:BuildModList() - self.build.itemsTab:AddItem(item, true) - - local slot = self.build.itemsTab.sockets[row.socketId] - if slot then - slot:SetSelItemId(item.id) - end - self.build.itemsTab:PopulateSlots() - self.build.buildFlag = true + self:executeActionPlan(row.actionPlan) end local function runRadiusJewelCompute(self, context) @@ -1335,7 +1665,8 @@ local function buildRadiusJewelPopupContext(self) return resultContextKey == getResultContextKey() end local function isResultApplicable(row) - return row ~= nil and row.applyRawText ~= nil and isResultContextCurrent(row.resultContextKey) + return row ~= nil and row.actionPlan ~= nil and isResultContextCurrent(row.resultContextKey) + and isActionPlanCurrent(self.build, row.actionPlan) end local function onCriteriaChanged(updateCriteria) cancelCompute() @@ -1629,25 +1960,38 @@ local function buildRadiusJewelPopupContext(self) if row.variantLabel and row.variantLabel ~= "" then t_insert(resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel }) end - local replacementItem - if row.replacedItemLabel or row.storedUnallocatedItemLabel then + local actionPlan = row.actionPlan + local action = actionPlan and actionPlan.kind or row.action + if actionPlan and not actionPlan.targetSocketAllocated then + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33This socket is unallocated and hidden from the Items panel." }) + t_insert(resultDetailListData, { height = 16, [1] = "^8Add to build keeps the jewel in the item list; placement uses the hidden socket." }) + end + local replacementItem = actionPlan and actionPlan.replacedTargetId + and self.build.itemsTab.items[actionPlan.replacedTargetId] + if not replacementItem and (row.replacedItemLabel or row.storedUnallocatedItemLabel) then local occupancy = self:getSocketOccupancyInfo(row.socketId) replacementItem = occupancy and occupancy.item end - if row.action == "keep" then + if actionPlan and actionPlan.sourceItemId then + local sourceText = actionPlan.sourceSocketId + and (actionPlan.sourceItemLabel .. " in " .. actionPlan.sourceSocketLabel) + or (actionPlan.sourceItemLabel .. " from Items") + t_insert(resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77" .. sourceText }) + elseif actionPlan then + t_insert(resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77New " .. (actionPlan.targetIdentity.uniqueName or "jewel") }) + end + if action == "equipped" then t_insert(resultDetailListData, { height = 16, [1] = "^8Already equipped" }) - elseif row.action == "moveReplace" then - t_insert(resultDetailListData, { height = 16, [1] = "^xBB88FFMove equipped jewel" }) - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. (row.replacedItemLabel or "?"), item = replacementItem }) - elseif row.action == "move" then + elseif action == "move" then t_insert(resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" }) - elseif row.replacedItemLabel then + if actionPlan and actionPlan.replacedTargetLabel then + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. actionPlan.replacedTargetLabel, item = replacementItem }) + end + elseif action == "replace" then t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" }) - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. row.replacedItemLabel, item = replacementItem }) - elseif row.storedUnallocatedItemLabel then - t_insert(resultDetailListData, { height = 16, [1] = "^2Use unallocated socket" }) - t_insert(resultDetailListData, { height = 16, [1] = "^8Stored jewel ignored until this socket is allocated." }) - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Apply will replace the stored jewel: ^7" .. row.storedUnallocatedItemLabel, item = replacementItem }) + local replacementLabel = actionPlan and actionPlan.replacedTargetLabel + or row.replacedItemLabel or row.storedUnallocatedItemLabel or "?" + t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. replacementLabel, item = replacementItem }) else t_insert(resultDetailListData, { height = 16, [1] = "^2Use free socket" }) end @@ -1673,6 +2017,7 @@ local function buildRadiusJewelPopupContext(self) t_insert(resultDetailListData, { height = 6, [1] = "" }) t_insert(resultDetailListData, { height = 16, [1] = row.resultNodes and (COL_META .. "No passives to allocate") or (COL_META .. "No passives in range") }) end + t_insert(resultDetailListData, { height = 16, [1] = "^8Passive allocations are not applied automatically." }) end controls.previewList = new("TextListControl"):TextListControl(TL, { rightPanelX, previewListY, rightPanelWidth, previewListHeight }, { { x = 0, align = "LEFT" }, { x = 210, align = "LEFT" } }, previewListData) @@ -2223,18 +2568,12 @@ local function buildRadiusJewelPopupContext(self) local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] detailNodeId = keystoneNode and keystoneNode.id or nil end - local action - if isEquippedSocket then - action = "keep" - elseif existingSocketId and r.replacedItemLabel then - action = "moveReplace" - elseif existingSocketId then - action = "move" - elseif r.replacedItemLabel then - action = "replace" - else - action = "new" - end + local actionPlan = self:buildActionPlan({ + socketId = r.socket.id, + socketLabel = r.socket.label, + targetIdentity = variantIdentity, + targetRawText = applyRawText, + }) t_insert(rows, { socketLabel = r.socket.label, socketId = r.socket.id, @@ -2257,7 +2596,9 @@ local function buildRadiusJewelPopupContext(self) jewelLimit = jewelLimit, isSocketIndependent = jewelType.isSocketIndependent, applyRawText = applyRawText, - action = action, + action = actionPlan and actionPlan.kind or nil, + actionPlan = actionPlan, + targetIdentity = variantIdentity, tooltipHeader = jewelType.isThread and "^7Socketing this jewel and allocating the best ring plan here will give you:" or jewelType.name == "Intuitive Leap" and "^7Socketing this jewel and allocating the best nodes here will give you:" or jewelType.isImpossibleEscape and "^7Socketing this jewel and allocating the best keystone plan here will give you:" @@ -2364,40 +2705,137 @@ local function buildRadiusJewelPopupContext(self) tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.") end - applySelectedResult = function() + local actionLabels = { + equip = "Equip", + move = "Move", + replace = "Replace", + equipped = "Equipped", + } + local function getSelectedActionRow() local idx = controls.resultsList.selIndex - local row = idx and controls.resultsList.list[idx] - local resultContextKey = getResultContextKey() + return idx and controls.resultsList.list[idx] or nil + end + local function getMatchingBuildItem(row) + if not row or not row.actionPlan then + return nil + end + return findCanonicalBuildItem(self.build.itemsTab, row.actionPlan.targetCanonicalKey) + end + local function executeSelectedResult(row, resultContextKey) if isResultApplicable(row) then applyRadiusJewelResult(self, row, resultContextKey) end end - controls.applyButton = new("ButtonControl"):ButtonControl(BL, { edgePadding + 480, bottomButtonY, 80, buttonHeight }, "Apply", applySelectedResult) + applySelectedResult = function() + local row = getSelectedActionRow() + local resultContextKey = getResultContextKey() + if not isResultApplicable(row) then + return + end + local plan = row.actionPlan + if not plan.targetSocketAllocated then + local actionLabel = actionLabels[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() + executeSelectedResult(row, resultContextKey) + end) + return + end + executeSelectedResult(row, resultContextKey) + end + local function addSelectedResultToBuild() + local row = getSelectedActionRow() + if isResultApplicable(row) then + self:executeAddToBuildPlan(row.actionPlan) + end + end + controls.addToBuildButton = new("ButtonControl"):ButtonControl(BL, { rightPanelX, bottomButtonY, 100, buttonHeight }, function() + local existingItem = getMatchingBuildItem(getSelectedActionRow()) + return existingItem and "In build" or "Add to build" + end, addSelectedResultToBuild) + controls.addToBuildButton.enabled = function() + local row = getSelectedActionRow() + return isResultApplicable(row) and not getMatchingBuildItem(row) + end + controls.addToBuildButton.tooltipFunc = function(tooltip) + local row = getSelectedActionRow() + 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 = getMatchingBuildItem(row) + if existingItem then + local location = existingSocketId and getSocketLabel(existingSocket, existingSocketId) or "Items" + tooltip:AddLine(16, "^8" .. itemName .. " is already in this build in " .. location .. ".") + if existingSocketId and self.build.spec.allocNodes[existingSocketId] == nil then + tooltip:AddLine(16, "^xFFAA33That socket is unallocated and hidden from the Items panel.") + end + return + end + if not isResultApplicable(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 + controls.applyButton = new("ButtonControl"):ButtonControl(BL, { rightPanelX + 110, bottomButtonY, 80, buttonHeight }, function() + local row = getSelectedActionRow() + local kind = row and row.actionPlan and row.actionPlan.kind + return actionLabels[kind] or "Equip" + end, applySelectedResult) controls.applyButton.enabled = function() - local idx = controls.resultsList.selIndex - return isResultApplicable(idx and controls.resultsList.list[idx]) + local row = getSelectedActionRow() + return isResultApplicable(row) and row.actionPlan.kind ~= "equipped" end controls.applyButton.tooltipFunc = function(tooltip) - local idx = controls.resultsList.selIndex - local row = idx and controls.resultsList.list[idx] - if row and row.applyRawText and not isResultApplicable(row) then + local row = getSelectedActionRow() + if row and row.actionPlan and not isResultApplicable(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.applyRawText then + if not row or not row.actionPlan then tooltip:Clear(true) - tooltip:AddLine(16, "^7Select a result to apply.") + tooltip:AddLine(16, "^7Select a result to equip.") return end + local plan = row.actionPlan tooltip:Clear(true) - tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket")) - tooltip:AddLine(16, "^8Adds the jewel to this build.") - if row.storedUnallocatedItemLabel then - tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.") + 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" .. actionLabels[plan.kind] .. " ^x33FF77" .. itemName .. " ^7in ^x33FF77" .. plan.targetSocketLabel) + if plan.sourceItemId then + local source = plan.sourceSocketId and plan.sourceSocketLabel or "Items" + tooltip:AddLine(16, "^7Source: ^x33FF77" .. plan.sourceItemLabel .. " ^7in " .. source) + else + tooltip:AddLine(16, "^7Source: ^x33FF77New jewel") + 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 " .. actionLabels[plan.kind]:lower() .. " it.") end - tooltip:AddLine(16, "^8Double-click a result to apply it.") end local function restoreFinderState() diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua index 5124e663fe..f0702f9423 100644 --- a/src/Classes/RadiusJewelResultsListControl.lua +++ b/src/Classes/RadiusJewelResultsListControl.lua @@ -31,11 +31,10 @@ local function formatPerPointDisplay(value, points) end local ACTION_COLORS = { - new = "^2", - move = "^x33AAFF", - moveReplace = "^xBB88FF", - replace = "^xFFAA33", - keep = "^8", + equip = "^2", + move = "^x33AAFF", + replace = "^xFFAA33", + equipped = "^8", } local function colorSocketLabel(row) return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel From 4d9a81233f9bb270b84fcb75866428e54baa52d4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 18:49:02 +0200 Subject: [PATCH 33/52] Keep Thread plans attached to their sockets Remove the two-pass top-five enrichment that associated sorted results through stale indices. Treat ring selection as a real result criterion, including Any ring, explicit filtering, preview, cache identity, restoration, and canonical labels. --- manifest.xml | 6 +- spec/System/TestRadiusJewelCompute_spec.lua | 85 +++++++++++++++++++++ spec/System/TestRadiusJewelFinder_spec.lua | 67 +++++++++++++--- src/Classes/RadiusJewelCompute.lua | 68 +---------------- src/Classes/RadiusJewelData.lua | 26 ++++--- src/Classes/RadiusJewelFinder.lua | 60 +++++++++++---- 6 files changed, 205 insertions(+), 107 deletions(-) diff --git a/manifest.xml b/manifest.xml index 3d1112a481..68ae2de9a1 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,10 +171,10 @@ - - + + - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 57682596c8..94e132ba96 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -736,6 +736,91 @@ describe("RadiusJewelCompute #radius-jewel", function() 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.buildSocketReplacementContext = function(_, _, socketId) + return { + socketNode = { id = socketId }, + baselineOutput = { Life = 0 }, + } + end + finder.collectDisconnectedPassiveCandidates = function(_, socketNode) + return { { id = socketNode.id * 10, name = "Candidate " .. socketNode.id } } + end + finder.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, _, skipPlanSteps) + 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 = variantLabel, + } + if not skipPlanSteps then + result.planSteps = { { detailText = result.detailText } } + end + return result + end + + local results = finder:computeThreadOfHopeSocketImpact( + sockets, "Life", { getTestVariants()[1] }, "fast", { }, nil, nil, nil, 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 ───────────────────────────────────────────────── diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index ad306617f9..14644a2bf4 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -116,6 +116,14 @@ describe("RadiusJewelFinder #radius-jewel", function() end end + local function getPreviewText(popup) + local lines = { } + for _, line in ipairs(popup.controls.previewList.list) do + table.insert(lines, line[1] or "") + end + return table.concat(lines, "\n") + end + local function openResultContextTestPopup(yieldDuringCompute) build.radiusJewelFinderState = nil local finder = makeFinder() @@ -337,14 +345,16 @@ describe("RadiusJewelFinder #radius-jewel", function() assertCachedResultsAreApplicable(popup, allJewelsContextKey, allJewelsResultCount) end) - it("keeps the Thread preview ring outside Find and Compute result identity", function() + 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.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) + computedVariants = variants return { { socket = sockets[1], @@ -358,31 +368,64 @@ describe("RadiusJewelFinder #radius-jewel", function() 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 anyRingPreview = getPreviewText(popup) + assert.matches("Multiple ring sizes available", anyRingPreview, 1, true) + for _, variant in ipairs(threadVariants) do + assert.is_nil(anyRingPreview:find(variant.ringLabel, 1, true)) + end popup.controls.findButton:Click() local findRow = popup.controls.resultsList.list[1] assert.is_not_nil(findRow) - local findResultContextKey = findRow.resultContextKey - popup.controls.resultsList.selIndex = 1 + local anyRingContextKey = findRow.resultContextKey popup.controls.threadVariantSelect.selFunc(2) + assertResultsCleared(popup) + local explicitRingPreview = getPreviewText(popup) + assert.matches(threadVariants[1].ringLabel, explicitRingPreview, 1, true) + assert.is_nil(explicitRingPreview: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(findRow, popup.controls.resultsList.list[1]) - assert.are.equal(findResultContextKey, popup.controls.resultsList.list[1].resultContextKey) - assert.is_true(popup.controls.applyButton.enabled()) + 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) - local resultContextKey = row.resultContextKey + 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.are.equal("computeSocket", popup.controls.resultsList.mode) - assert.are.equal(row, popup.controls.resultsList.list[1]) - assert.are.equal(resultContextKey, popup.controls.resultsList.list[1].resultContextKey) - assert.is_true(popup.controls.applyButton.enabled()) - assert.are.equal(threadVariants[1].name, build.radiusJewelFinderState.threadVariantName) + assert.are.equal("findThread", popup.controls.resultsList.mode) + assert.are.equal(anyRingContextKey, popup.controls.resultsList.list[1].resultContextKey) + assert.is_nil(build.radiusJewelFinderState.threadVariantName) + end) + + it("restores an explicit Thread ring and its cached result view", 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() + local resultContextKey = popup.controls.resultsList.list[1].resultContextKey + + popup.controls.closeButton:Click() + local reopenedPopup = finder:Open() + + assert.are.equal(2, reopenedPopup.controls.threadVariantSelect.selIndex) + assert.are.equal("findThread", reopenedPopup.controls.resultsList.mode) + assert.are.equal(resultContextKey, reopenedPopup.controls.resultsList.list[1].resultContextKey) + assert.are.equal(RadiusJewelData.getThreadOfHopeVariants()[1].name, + build.radiusJewelFinderState.threadVariantName) end) it("cancels a suspended Compute when a result criterion changes", function() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index c64b55c46e..e0fff2189e 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -652,8 +652,6 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian threadItems[variantIndex] = item end - -- Pass 1: find best ring variant per socket (skip plan steps, with early pruning) - local pendingPlanSteps = { } for socketIndex, socket in ipairs(sockets) do progressTick(progress, socketIndex - 1, #sockets, socket.label) local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets) @@ -664,10 +662,10 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian local socketNode = replacementContext.socketNode local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) local bestResult - local bestVariantIndex, bestCandidates for variantIndex, threadVariant in ipairs(threadVariants) do local variantProgress = progressChild(socketProgress, (variantIndex - 1) / #threadVariants, 1 / #threadVariants) local item = threadItems[variantIndex] + local ringLabel = threadVariant.ringLabel or (threadVariant.name .. " Ring") local candidates = self:collectDisconnectedPassiveCandidates(socketNode, { radiusIndex = threadVariant.radiusIndex, notableOrKeystoneOnly = skipPlanSteps or methodId == "fast", @@ -679,9 +677,9 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian if methodId == "fast" then local cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, threadVariant.name .. " Ring", planCache[cacheKey], socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes, true, earlyPruneThreshold) + result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, planCache[cacheKey], socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold) else - result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, threadVariant.name .. " Ring", socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes) + result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes) end if not result.pruned then result.variant = threadVariant @@ -690,8 +688,6 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount) or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and threadVariant.radiusIndex < bestResult.variant.radiusIndex) then bestResult = result - bestVariantIndex = variantIndex - bestCandidates = candidates end end end @@ -701,17 +697,6 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian bestResult.replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil bestResult.storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil t_insert(results, bestResult) - if not skipPlanSteps and methodId == "fast" and bestVariantIndex then - t_insert(pendingPlanSteps, { - replacementContext = replacementContext, - socketBaseline = socketBaseline, - bestVariantIndex = bestVariantIndex, - bestCandidates = bestCandidates, - socketBasePoints = socketBasePoints, - cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id), - resultIndex = #results, - }) - end end progressTick(socketProgress, 1, 1, socket.label) end @@ -724,53 +709,6 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian return a.variant.radiusIndex < b.variant.radiusIndex end) - -- Pass 2: compute plan steps only for top results (single-jewel mode) - if #pendingPlanSteps > 0 then - -- Build lookup: which result indices need plan steps (top 5 by delta) - local topResultIndices = { } - for i = 1, math.min(5, #results) do - topResultIndices[results[i]] = true - end - for _, pending in ipairs(pendingPlanSteps) do - local result = results[pending.resultIndex] - -- resultIndex may point to another row after sorting; check by reference - if not topResultIndices[result] then - goto continuePending - end - local replacementContext = pending.replacementContext - local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - pending.socketBasePoints, 0) or nil - local fullResult = self:computeDisconnectedPassiveFastPlan( - calcFunc, - replacementContext, - replacementContext.baselineOutput, - pending.socketBaseline, - replacementContext.socketNode, - threadItems[pending.bestVariantIndex], - impactStat, - pending.bestCandidates, - threadVariants[pending.bestVariantIndex].name .. " Ring", - planCache[pending.cacheKey], - nil, - nil, - maxAdditionalNodes, - false, - nil - ) - fullResult.variant = threadVariants[pending.bestVariantIndex] - fullResult.socket = result.socket - fullResult.replacedItemLabel = result.replacedItemLabel - fullResult.storedUnallocatedItemLabel = result.storedUnallocatedItemLabel - -- Replace in-place in results - for i, r in ipairs(results) do - if r == result then - results[i] = fullResult - break - end - end - ::continuePending:: - end - end - return results, realBaseline end diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 69178918d3..0fe93736c8 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -208,6 +208,7 @@ function M.getThreadOfHopeVariants() local variantRawText = mustGetUniqueVariantRawText("Thread of Hope", variantIndex) local variant = { name = variantName:gsub(" Ring$", ""), + ringLabel = variantName, rawText = variantRawText, radiusIndex = getRadiusIndexFromRawText(variantRawText), } @@ -688,22 +689,23 @@ local function previewVariantOrGroup(groupName, variant) 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 - if ringName then - 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 .. ")" + 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 diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index e70f7ed49d..498d29dd82 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -837,9 +837,9 @@ local function buildRadiusJewelPopupSetup(self) } local threadVariants = RadiusJewelData.getThreadOfHopeVariants() - local threadVariantLabels = { } + local threadVariantLabels = { "Any ring" } for _, variant in ipairs(threadVariants) do - t_insert(threadVariantLabels, variant.name .. " Ring") + t_insert(threadVariantLabels, variant.ringLabel or (variant.name .. " Ring")) end local impactStatLabels = { } for _, stat in ipairs(IMPACT_STATS) do @@ -1136,7 +1136,7 @@ local function runRadiusJewelFind(self, context, makePreferred) score = r.score or 0, scorePerPoint = scorePerPoint, sortValue = sortValue, - variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.name .. " Ring") + variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.ringLabel or (r.variant.name .. " Ring")) or r.variant.dropdownLabel or r.variant.name) or "", detailText = detailText, detailNodeId = detailNodeId, @@ -1152,8 +1152,11 @@ local function runRadiusJewelFind(self, context, makePreferred) stampResultRows(rows, resultContextKey) controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)") local elapsed = formatElapsed(searchStartTime) + local threadLabel = #threadVariants == 1 + and ("Thread of Hope (" .. (threadVariants[1].ringLabel or (threadVariants[1].name .. " Ring")) .. ")") + or "Thread of Hope (Any ring)" controls.statusLabel.label = (isThreadBestVariantSearch - and s_format("^7Thread of Hope | %d | score/pt", #results) + and s_format("^7%s | %d | score/pt", threadLabel, #results) or isImpossibleEscapeBestVariantSearch and s_format("^7Impossible Escape | %d | score/pt", #results) or isSplitPersonalitySearch @@ -1380,6 +1383,11 @@ local function runRadiusJewelCompute(self, context) else local displayedVariants = getSelectedVariants() local itemLabel = selectedJewelType.name + if selectedJewelType.isThread then + itemLabel = #threadVariants == 1 + and (itemLabel .. " (" .. (threadVariants[1].ringLabel or (threadVariants[1].name .. " Ring")) .. ")") + or (itemLabel .. " (Any ring)") + end local socketResults, baseline local rows local useVariantPartitions = displayedVariants and #displayedVariants > 1 @@ -1501,7 +1509,7 @@ local function buildRadiusJewelPopupContext(self) local showLegacy = false local activeJewelTypes = { } local selectedJewelType - local selectedThreadVariant = threadVariants[1] + local selectedThreadVariant local selectedJewelVariant local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1] local selectedMaxPoints = 20 @@ -1562,11 +1570,14 @@ local function buildRadiusJewelPopupContext(self) or selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0) local computeMethodKey = supportsComputeMethods and selectedComputeMethod and selectedComputeMethod.id or "" local legacyKey = selectedJewelType and selectedJewelType.isAllJewels and showLegacy and "1" or "0" + local threadVariantKey = selectedJewelType and selectedJewelType.isThread + 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 "", @@ -1794,6 +1805,10 @@ local function buildRadiusJewelPopupContext(self) return variants end + local function getSelectedThreadVariants() + return selectedThreadVariant and { selectedThreadVariant } or threadVariants + end + local function buildPreviewLinesForJewelType(jewelType, previewVariantOverride) if not jewelType then return nil @@ -1845,6 +1860,9 @@ local function buildRadiusJewelPopupContext(self) if type(lines) ~= "table" then return nil end + if jewelType.isThread then + return lines + end local genericLines = { } local blankCount = 0 @@ -2254,11 +2272,12 @@ local function buildRadiusJewelPopupContext(self) controls.allJewelsViewSelect.shown = false -- Thread ring selector (shown when Thread of Hope selected) - controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Preview ring:") + controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Ring:") controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx) - selectedThreadVariant = threadVariants[idx] - saveFinderState() - updatePreview() + onCriteriaChanged(function() + selectedThreadVariant = idx == 1 and nil or threadVariants[idx - 1] + updatePreview() + end) end) controls.threadVariantLabel.shown = false controls.threadVariantSelect.shown = false @@ -2436,11 +2455,18 @@ local function buildRadiusJewelPopupContext(self) end end controls.threadVariantSelect.tooltipFunc = function(tooltip, mode, index) - local variant = threadVariants[index] - if not selectedJewelType or not variant then + 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() @@ -2518,7 +2544,9 @@ local function buildRadiusJewelPopupContext(self) local isEquippedSocket = equippedSocketIds[r.socket.id] local points = isEquippedSocket and 0 or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil }) - local variantLabel = r.variant and (r.variant.dropdownLabel or r.variant.name) or "" + local variantLabel = r.variant and (jewelType.isThread + and (r.variant.ringLabel or (r.variant.name .. " Ring")) + 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 applyRawText = variantIdentity and variantIdentity.rawText or r.variant and r.variant.rawText or jewelType.rawText @@ -2612,6 +2640,8 @@ local function buildRadiusJewelPopupContext(self) controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, headerInputY, 72, buttonHeight }, "Compute", function() local resultContextKey = getResultContextKey() + local selectedThreadVariants = selectedJewelType and selectedJewelType.isThread + and getSelectedThreadVariants() or threadVariants runRadiusJewelCompute(self, { controls = controls, computeState = computeState, @@ -2625,7 +2655,7 @@ local function buildRadiusJewelPopupContext(self) selectedJewelSupportsComputeMethods = selectedJewelSupportsComputeMethods, activeJewelTypes = activeJewelTypes, jewelSockets = jewelSockets, - threadVariants = threadVariants, + threadVariants = selectedThreadVariants, finderState = finderState, selectedMaxPoints = selectedMaxPoints, selectedOccupiedMode = selectedOccupiedMode, @@ -2680,7 +2710,7 @@ local function buildRadiusJewelPopupContext(self) controls = controls, treeData = treeData, radiusIndexByLabel = radiusIndexByLabel, - threadVariants = threadVariants, + threadVariants = getSelectedThreadVariants(), jewelSockets = jewelSockets, selectedJewelType = selectedJewelType, selectedJewelVariant = selectedJewelVariant, @@ -2915,7 +2945,7 @@ local function buildRadiusJewelPopupContext(self) for i, variant in ipairs(threadVariants) do if variant.name == finderState.threadVariantName then selectedThreadVariant = variant - controls.threadVariantSelect.selIndex = i + controls.threadVariantSelect.selIndex = i + 1 break end end From 0ebcfc713e28aa955f28874ec03166c9a2183547 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 19:08:11 +0200 Subject: [PATCH 34/52] Isolate Impossible Escape result groups Carry each representative group identity through fan-out and rebuild full plan details in the group's own budget and replacement context. Stop reusing or copying plans across groups that happen to choose the same variant. --- manifest.xml | 2 +- spec/System/TestRadiusJewelCompute_spec.lua | 76 +++++++++++++++++++++ src/Classes/RadiusJewelCompute.lua | 47 ++++--------- 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/manifest.xml b/manifest.xml index 68ae2de9a1..adad24738e 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,7 +171,7 @@ - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 94e132ba96..eb4369029b 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -670,6 +670,82 @@ describe("RadiusJewelCompute #radius-jewel", function() 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.collectDisconnectedPassiveCandidates = function() + return { + { id = -101, name = "First" }, + { id = -102, name = "Second" }, + { id = -103, name = "Third" }, + } + end + finder.buildSocketReplacementContext = function(_, _, socketId) + return { + socketNode = { id = socketId }, + occupancy = occupancyBySocketId[socketId], + baselineOutput = { Life = 0 }, + } + end + finder.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, maxAdditionalNodes, skipPlanSteps) + local result = { + delta = socketNode.id == freeSocket.id and 100 or 90, + addedNodeCount = maxAdditionalNodes, + resultNodes = { socketNode.id * 10 }, + resultNodeLabels = { "Plan for " .. socketNode.id }, + baseOutput = { Life = 0 }, + compareOutput = { Life = socketNode.id }, + detailText = "plan-" .. socketNode.id, + variantLabel = variantLabel, + } + if not 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 = finder:computeImpossibleEscapeSocketImpact( + { freeSocket, occupiedSocket }, "Life", { variant }, "fast", { }, nil, 5, nil, 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() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index e0fff2189e..962489c589 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -848,31 +848,11 @@ local function groupImpossibleEscapeSockets(self, sockets, maxTotalPoints, occup return groupedOrder end -local function getMaxCandidateCount(variantDataByName) - local maxCandidateCount = 0 - for _, variantData in pairs(variantDataByName) do - if #variantData.candidates > maxCandidateCount then - maxCandidateCount = #variantData.candidates - end - end - return maxCandidateCount -end - local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, variants, variantDataByName, methodId, impactStat, statField, calcFunc, planCache, progress) local bestResultByGroupKey = { } local totalPlanCount = #groupedOrder * #variants local currentPlanIndex = 0 - local maxCandidateCount = getMaxCandidateCount(variantDataByName) - local previousFreeResult for _, groupEntry in ipairs(groupedOrder) do - local isFreeGroup = not groupEntry.groupKey:match("^occupied:") - -- Groups are sorted by remaining points. Once they cover every candidate, - -- reuse the first free result; skipped variants still advance progress. - if isFreeGroup and previousFreeResult and groupEntry.remainingPoints >= maxCandidateCount then - bestResultByGroupKey[groupEntry.groupKey] = previousFreeResult - currentPlanIndex = currentPlanIndex + #variants - goto continueGroup - end local representativeSocket = groupEntry.representativeSocket local replacementContext = self:buildSocketReplacementContext(calcFunc, representativeSocket.id) local representativeSocketNode = replacementContext.socketNode @@ -934,11 +914,10 @@ local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, end progressTick(planProgress, 1, 1, variant.name) end - bestResultByGroupKey[groupEntry.groupKey] = bestResult - if isFreeGroup and not previousFreeResult then - previousFreeResult = bestResult + if bestResult then + bestResult.impossibleEscapeGroupKey = groupEntry.groupKey end - ::continueGroup:: + bestResultByGroupKey[groupEntry.groupKey] = bestResult end return bestResultByGroupKey end @@ -951,6 +930,7 @@ local function fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGro 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 @@ -968,18 +948,15 @@ local function fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGro end local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestResultByGroupKey, variantDataByName, impactStat, statField, calcFunc, planCache) - local topResult = results[1] - local variantData = variantDataByName[topResult.variant.name] - if not variantData then - return - end for _, groupEntry in ipairs(groupedOrder) do local bestResult = bestResultByGroupKey[groupEntry.groupKey] - if bestResult and bestResult.variant.name == topResult.variant.name then + 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, topResult.variant.name, replacementContext) + local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, bestResult.variant.name, replacementContext) + planCache[cacheKey] = planCache[cacheKey] or { } local fullResult = self:computeDisconnectedPassiveFastPlan( calcFunc, replacementContext, @@ -989,7 +966,7 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR variantData.item, impactStat, variantData.candidates, - topResult.variant.name, + bestResult.variant.name, planCache[cacheKey], nil, nil, @@ -997,9 +974,10 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR false, nil ) - fullResult.variant = topResult.variant + fullResult.variant = bestResult.variant + fullResult.impossibleEscapeGroupKey = groupEntry.groupKey for i, result in ipairs(results) do - if result.variant.name == topResult.variant.name then + if result.impossibleEscapeGroupKey == groupEntry.groupKey then local updated = copyTableSafe(fullResult, false, true) updated.socket = result.socket updated.replacedItemLabel = result.replacedItemLabel @@ -1007,7 +985,6 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR results[i] = updated end end - break end end end From abee7f40a61d50dfe58eb733ac8300803abb7c26 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 19:22:24 +0200 Subject: [PATCH 35/52] Restore Split Personality state on every exit Limit the temporary socket distance mutation to protected calculator calls so cooperative cancellation never abandons modified live tree state. Restore the original value before returning or propagating calculator errors. --- manifest.xml | 2 +- spec/System/TestRadiusJewelCompute_spec.lua | 59 +++++++++++++++++++++ src/Classes/RadiusJewelCompute.lua | 24 ++++++--- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/manifest.xml b/manifest.xml index adad24738e..ce6baf979a 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,7 +171,7 @@ - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index eb4369029b..7e91d05255 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -482,6 +482,65 @@ describe("RadiusJewelCompute #radius-jewel", function() 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() + makeFinder():computeSplitPersonalitySocketImpact({ { + id = socket.id, + label = socket.label, + classStartDist = splitDistance, + pathDist = socket.pathDist, + } }, "Life", variants, progress, nil, { 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() + makeFinder():computeSplitPersonalitySocketImpact({ { + id = socket.id, + label = socket.label, + classStartDist = (previousDistance or 0) + 100, + pathDist = socket.pathDist, + } }, "Life", variants, nil, nil, { 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() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 962489c589..1aba107cf2 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -43,6 +43,19 @@ local function progressChild(progress, startFraction, spanFraction) return progress 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 @@ -728,14 +741,11 @@ function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants local socketNode = replacementContext.socketNode local slotName = replacementContext.slotName local splitDistance = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id) - local previousDistance = socketNode.distanceToClassStart - - socketNode.distanceToClassStart = splitDistance - local baselineOutput = calcFunc({ + local baselineOutput = calculateWithSocketDistance(calcFunc, { addNodes = { [socketNode] = true }, repSlotName = slotName, repItem = replacementContext.baselineItem, - }) + }, socketNode, splitDistance) local bestResult for variantIdx, variant in ipairs(variants) do @@ -748,7 +758,8 @@ function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants if override.spec then override.spec.nodes[socketNode.id].distanceToClassStart = splitDistance end - local output = calcFunc(override) + 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 @@ -767,7 +778,6 @@ function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants end end - socketNode.distanceToClassStart = previousDistance if bestResult then bestResult.splitDistance = splitDistance t_insert(results, bestResult) From 7d50870317af29630780c23b6b6bba8bde2faf82 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 19:43:19 +0200 Subject: [PATCH 36/52] Evaluate every Fast radius jewel plan Individual passive gains are not an upper bound for combinations with non-additive interactions. Always run the final combined calculation before ranking Thread of Hope and Impossible Escape variants. --- manifest.xml | 2 +- spec/System/TestRadiusJewelCompute_spec.lua | 35 +++++++++++++++ src/Classes/RadiusJewelCompute.lua | 47 +++++++-------------- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/manifest.xml b/manifest.xml index ce6baf979a..e1eeadfdf6 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,7 +171,7 @@ - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 7e91d05255..19ddbdbe57 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -436,6 +436,41 @@ describe("RadiusJewelCompute #radius-jewel", function() 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.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:computeDisconnectedPassiveFastPlan( + calcFunc, { }, { Life = 0 }, 0, socketNode, { }, "Life", + { firstNode, secondNode }, "Combined", { }, nil, nil, 2, true, + previousBestDelta) + + assert.are.equal(20, result.delta) + assert.is_nil(result.pruned) + assert.is_true(evaluatedCombinedNodes) + end) + + end) + describe("computeSplitPersonalitySocketImpact", function() local function getSockets() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index 1aba107cf2..a446107eac 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -393,7 +393,7 @@ function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementCont return result end -function Class:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold) +function Class:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps) impactStat = normalizeImpactStat(impactStat) local jewelOnlyOutput, jewelOnlyValue local function ensureJewelOnly() @@ -437,18 +437,11 @@ function Class:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, end) local chosenNodes = { } - local estimatedDelta = 0 for i, entry in ipairs(scoredCandidates) do if maxAdditionalNodes and i > maxAdditionalNodes then break end t_insert(chosenNodes, entry.node) - estimatedDelta = estimatedDelta + entry.delta - end - - -- Early pruning: if the sum of individual gains can't beat the current best, skip the slower final calcFunc - if earlyPruneThreshold and estimatedDelta <= earlyPruneThreshold then - return { delta = estimatedDelta, pruned = true } end local addNodes = { [socketNode] = true } @@ -685,23 +678,20 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian }) if #candidates > 0 then local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil - local earlyPruneThreshold = bestResult and bestResult.delta or nil local result if methodId == "fast" then local cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, planCache[cacheKey], socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold) + result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, planCache[cacheKey], socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes, skipPlanSteps) else result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes) end - if not result.pruned then - result.variant = threadVariant - if not bestResult - or result.delta > bestResult.delta - or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount) - or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and threadVariant.radiusIndex < bestResult.variant.radiusIndex) then - bestResult = result - end + 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 @@ -874,7 +864,6 @@ local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, local variantData = variantDataByName[variant.name] if variantData then local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil - local earlyPruneThreshold = bestResult and bestResult.delta or nil local result if methodId == "fast" then local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, variant.name, replacementContext) @@ -893,8 +882,7 @@ local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, variant.name, planProgress, maxAdditionalNodes, - true, - earlyPruneThreshold + true ) else result = self:computeDisconnectedPassiveSimulatedPlan( @@ -912,14 +900,12 @@ local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, maxAdditionalNodes ) end - if not result.pruned then - result.variant = variant - if not bestResult - or result.delta > bestResult.delta - or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount) - or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and variant.name < bestResult.variant.name) then - bestResult = result - end + 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) @@ -981,8 +967,7 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR nil, nil, maxAdditionalNodes, - false, - nil + false ) fullResult.variant = bestResult.variant fullResult.impossibleEscapeGroupKey = groupEntry.groupKey From 6e7ac2e978dfaba05898caa47408e69cd1b7d747 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 20:46:26 +0200 Subject: [PATCH 37/52] Give radius jewel computation an explicit owner Replace the class-mutating compute mixin with a dedicated per-finder object. Keep calculation state and helpers behind that boundary so production callers and tests use the same explicit owner. --- manifest.xml | 4 +- spec/System/TestRadiusJewelActions_spec.lua | 6 +- spec/System/TestRadiusJewelCompute_spec.lua | 136 ++++++++++---------- spec/System/TestRadiusJewelData_spec.lua | 14 +- spec/System/TestRadiusJewelFinder_spec.lua | 32 ++--- src/Classes/RadiusJewelCompute.lua | 128 ++++++++++++++---- src/Classes/RadiusJewelFinder.lua | 95 +++----------- 7 files changed, 218 insertions(+), 197 deletions(-) diff --git a/manifest.xml b/manifest.xml index e1eeadfdf6..887df8d6b0 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,10 +171,10 @@ - + - + diff --git a/spec/System/TestRadiusJewelActions_spec.lua b/spec/System/TestRadiusJewelActions_spec.lua index 998d37123b..8b6f817864 100644 --- a/spec/System/TestRadiusJewelActions_spec.lua +++ b/spec/System/TestRadiusJewelActions_spec.lua @@ -118,7 +118,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() finder.buildJewelSockets = function() return { { id = targetSocketId, label = "Target socket", pathDist = 0 } } end - finder.computeThreadOfHopeSocketImpact = function(_, sockets) + finder.compute.computeThreadOfHopeSocketImpact = function(_, sockets) return { { socket = sockets[1], @@ -150,7 +150,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() finder.buildJewelSockets = function() return { { id = targetSocketId, label = "Free target", pathDist = 0 } } end - finder.computeSocketImpact = function(_, sockets) + finder.compute.computeSocketImpact = function(_, sockets) return { { socket = sockets[1], @@ -183,7 +183,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() finder.buildJewelSockets = function() return { { id = targetSocketId, label = "Target socket", pathDist = 0 } } end - finder.computeBestVariantSocketImpact = function(_, sockets) + finder.compute.computeBestVariantSocketImpact = function(_, sockets) return { { socket = sockets[1], diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 19ddbdbe57..fc6e46fb94 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -41,7 +41,7 @@ describe("RadiusJewelCompute #radius-jewel", function() it("returns one result per socket and uses the best variant", function() local sockets = getSockets() local variants = getLightOfMeaningVariants() - local results, baseline = makeFinder():computeBestVariantSocketImpact(sockets, variants, "Life") + local results, baseline = makeFinder().compute:computeBestVariantSocketImpact(sockets, variants, "Life") assert.is_true(#results > 0, "expected at least one result") assert.is_true(#results <= #sockets, "should return no more than socket count") assert.is_number(baseline) @@ -55,7 +55,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("keeps comparison snapshots free of nested requirement sources", function() - local results = makeFinder():computeBestVariantSocketImpact(getSockets(), getLightOfMeaningVariants(), "Life") + local results = makeFinder().compute:computeBestVariantSocketImpact(getSockets(), getLightOfMeaningVariants(), "Life") local nestedRequirementKeys = { "ReqStrFailList", "ReqDexFailList", "ReqIntFailList", "ReqOmniFailList", "ReqStrItem", "ReqDexItem", "ReqIntItem", "ReqOmniItem", @@ -71,14 +71,14 @@ describe("RadiusJewelCompute #radius-jewel", function() it("results are sorted by delta descending", function() local sockets = getSockets() - local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + local results, _ = makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") assert.is_true(isSorted(results, "delta"), "results should be sorted by delta descending") end) it("Life variant selected on sockets where it is better than others", function() local sockets = getSockets() - local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + local results, _ = makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") local hasLife = false for _, r in ipairs(results) do if r.variant.name == "Life" then hasLife = true; break end @@ -89,7 +89,7 @@ describe("RadiusJewelCompute #radius-jewel", function() it("restores TotalLife after compute", function() local sockets = getSockets() local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") local after = build.calcsTab.mainOutput["Life"] assert.are.equal(before, after) end) @@ -97,13 +97,13 @@ describe("RadiusJewelCompute #radius-jewel", function() it("restores socket and item state after compute", function() local sockets = getSockets() local before = snapshotFinderState() - makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") assertFinderStateUnchanged(before) end) it("respects occupiedMode filter", function() local sockets = getSockets() - local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" }) + local results, _ = makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" }) assert.is_true(#results > 0, "expected results with occupied mode 'all'") end) @@ -135,7 +135,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - local results = makeFinder():computeBestVariantSocketImpact({ { + local results = makeFinder().compute:computeBestVariantSocketImpact({ { id = socketId, label = "Historic socket", pathDist = 0, @@ -155,7 +155,7 @@ describe("RadiusJewelCompute #radius-jewel", function() local testSocket for _, socket in ipairs(finder:buildJewelSockets(radiusIndex)) do local socketNode = build.spec.nodes[socket.id] - local candidates = finder:collectDisconnectedPassiveCandidates(socketNode, { + local candidates = finder.compute:collectDisconnectedPassiveCandidates(socketNode, { radiusIndex = radiusIndex, }) if build.spec.allocNodes[socket.id] and #candidates > 0 then @@ -181,7 +181,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - local results = finder:computeIntuitiveLeapSocketImpact( + local results = finder.compute:computeIntuitiveLeapSocketImpact( { testSocket }, "Life", nil, "fast", { }, nil, 0, { id = "all" }, true) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator @@ -205,7 +205,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - local results = makeFinder():computeSplitPersonalitySocketImpact({ { + local results = makeFinder().compute:computeSplitPersonalitySocketImpact({ { id = socketId, label = "Historic socket", classStartDist = splitDistance, @@ -244,7 +244,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - makeFinder():computeSplitPersonalitySocketImpact({ { + makeFinder().compute:computeSplitPersonalitySocketImpact({ { id = testSocket.id, label = "Stored Historic socket", classStartDist = 42, @@ -269,7 +269,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end it("returns a table (may be empty if all sockets occupied)", function() - local results, baseline = makeFinder():computeSocketImpact( + local results, baseline = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") assert.is_table(results) assert.is_number(baseline) @@ -277,19 +277,19 @@ describe("RadiusJewelCompute #radius-jewel", function() it("returns the current main output as baseline for the selected stat", function() local expectedBaseline = build.calcsTab.mainOutput["Life"] - local _, baseline = makeFinder():computeSocketImpact( + local _, baseline = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") assert.are.equal(expectedBaseline, baseline) end) it("returns at least one result for the fixture build", function() - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") assert.is_true(#results > 0, "expected at least one empty jewel socket result") end) it("MoM: only tests empty sockets (selItemId == 0)", function() - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") for _, r in ipairs(results) do local slot = build.itemsTab.sockets[r.socket.id] @@ -299,7 +299,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("MoM: results sorted by delta descending", function() - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") assert.is_true(isSorted(results, "delta"), "MoM socket results should be sorted by delta descending") @@ -307,31 +307,31 @@ describe("RadiusJewelCompute #radius-jewel", function() it("MoM: restores TotalLife after compute", function() local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + makeFinder().compute:computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") assert.are.equal(before, build.calcsTab.mainOutput["Life"]) end) it("MoM: restores socket and item state after compute", function() local before = snapshotFinderState() - makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + makeFinder().compute:computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") assertFinderStateUnchanged(before) end) it("UI: restores TotalLife after compute", function() local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "Life") + makeFinder().compute:computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "Life") assert.are.equal(before, build.calcsTab.mainOutput["Life"]) end) it("AK: restores TotalLife after compute", function() local before = build.calcsTab.mainOutput["Life"] - makeFinder():computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "Life") + makeFinder().compute:computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "Life") assert.are.equal(before, build.calcsTab.mainOutput["Life"]) end) it("respects max total points for standard compute", function() local maxPoints = 2 - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, maxPoints) for _, r in ipairs(results) do assert.is_true((r.socket.pathDist or 0) <= maxPoints, @@ -340,7 +340,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("occupied sockets (36634, 61419, 41263) are skipped", function() - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } for _, r in ipairs(results) do @@ -350,7 +350,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("occupiedMode 'all' includes occupied sockets", function() - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) local occupiedIds = { [36634] = true, [61419] = true, [41263] = true } local foundOccupied = false @@ -363,9 +363,9 @@ describe("RadiusJewelCompute #radius-jewel", function() it("occupiedMode 'safe' returns at least as many results as 'free'", function() local sockets = getSockets() - local freeResults, _ = makeFinder():computeSocketImpact( + local freeResults, _ = makeFinder().compute:computeSocketImpact( sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") - local safeResults, _ = makeFinder():computeSocketImpact( + local safeResults, _ = makeFinder().compute:computeSocketImpact( sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "safe" }) assert.is_true(#safeResults >= #freeResults, "safe mode should include at least all free sockets") @@ -373,16 +373,16 @@ describe("RadiusJewelCompute #radius-jewel", function() it("occupiedMode 'all' returns more results than 'free' (build has occupied sockets)", function() local sockets = getSockets() - local freeResults, _ = makeFinder():computeSocketImpact( + local freeResults, _ = makeFinder().compute:computeSocketImpact( sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") - local allResults, _ = makeFinder():computeSocketImpact( + local allResults, _ = makeFinder().compute:computeSocketImpact( sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) assert.is_true(#allResults > #freeResults, "all mode should include more sockets than free mode (occupied sockets exist)") end) it("each result has socket, value and delta fields", function() - local results, _ = makeFinder():computeSocketImpact( + local results, _ = makeFinder().compute:computeSocketImpact( getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") local seenSocketIds = {} for _, r in ipairs(results) do @@ -406,7 +406,7 @@ describe("RadiusJewelCompute #radius-jewel", function() it("respects max total points for Intuitive Leap", function() local maxPoints = 4 - local results, _ = makeFinder():computeIntuitiveLeapSocketImpact( + local results, _ = makeFinder().compute:computeIntuitiveLeapSocketImpact( getSockets(), "Life", false, "simulated_greedy", { }, nil, maxPoints) for _, r in ipairs(results) do local totalPoints = (r.socket.pathDist or 0) + (r.addedNodeCount or 0) @@ -426,9 +426,9 @@ describe("RadiusJewelCompute #radius-jewel", function() assert.is_not_nil(targetSocket, "expected at least one socket with path points") local maxPoints = targetSocket.pathDist local sockets = { targetSocket } - local fastResults = makeFinder():computeIntuitiveLeapSocketImpact( + local fastResults = makeFinder().compute:computeIntuitiveLeapSocketImpact( sockets, "Life", false, "fast", { }, nil, maxPoints) - local simulatedResults = makeFinder():computeIntuitiveLeapSocketImpact( + local simulatedResults = makeFinder().compute:computeIntuitiveLeapSocketImpact( sockets, "Life", false, "simulated_greedy", { }, nil, maxPoints) assert.are.equal(0, fastResults[1].addedNodeCount or 0) assert.are.equal(0, simulatedResults[1].addedNodeCount or 0) @@ -444,7 +444,7 @@ describe("RadiusJewelCompute #radius-jewel", function() local firstNode = { id = 2, name = "First" } local secondNode = { id = 3, name = "Second" } local evaluatedCombinedNodes = false - finder.buildSocketReplacementOverride = function(_, _, item, addNodes) + finder.compute.buildSocketReplacementOverride = function(_, _, item, addNodes) return { item = item, addNodes = addNodes } end local function calcFunc(override) @@ -459,7 +459,7 @@ describe("RadiusJewelCompute #radius-jewel", function() local previousBestDelta = 5 -- Keep passing the historical pruning threshold so this test fails if that unsafe bound is restored. - local result = finder:computeDisconnectedPassiveFastPlan( + local result = finder.compute:computeDisconnectedPassiveFastPlan( calcFunc, { }, { Life = 0 }, 0, socketNode, { }, "Life", { firstNode, secondNode }, "Combined", { }, nil, nil, 2, true, previousBestDelta) @@ -490,7 +490,7 @@ describe("RadiusJewelCompute #radius-jewel", function() previousDistanceBySocketId[socket.id] = build.spec.nodes[socket.id] and build.spec.nodes[socket.id].distanceToClassStart end - local results, baseline = makeFinder():computeSplitPersonalitySocketImpact(sockets, "Life", variants) + local results, baseline = makeFinder().compute:computeSplitPersonalitySocketImpact(sockets, "Life", variants) assert.is_true(#results > 0, "expected split personality results") assert.is_number(baseline) @@ -508,7 +508,7 @@ describe("RadiusJewelCompute #radius-jewel", function() it("respects max total points", function() local maxPoints = 4 - local results, _ = makeFinder():computeSplitPersonalitySocketImpact( + local results, _ = makeFinder().compute:computeSplitPersonalitySocketImpact( getSockets(), "Life", variants, nil, maxPoints) for _, result in ipairs(results) do local totalPoints = (result.socket.pathDist or 0) @@ -530,7 +530,7 @@ describe("RadiusJewelCompute #radius-jewel", function() return self end local computation = coroutine.create(function() - makeFinder():computeSplitPersonalitySocketImpact({ { + makeFinder().compute:computeSplitPersonalitySocketImpact({ { id = socket.id, label = socket.label, classStartDist = splitDistance, @@ -562,7 +562,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end local ok, err = pcall(function() - makeFinder():computeSplitPersonalitySocketImpact({ { + makeFinder().compute:computeSplitPersonalitySocketImpact({ { id = socket.id, label = socket.label, classStartDist = (previousDistance or 0) + 100, @@ -612,7 +612,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - makeFinder():computeBestVariantSocketImpact({ { + makeFinder().compute:computeBestVariantSocketImpact({ { id = socketId, label = "Cluster socket", pathDist = 0, @@ -644,7 +644,7 @@ describe("RadiusJewelCompute #radius-jewel", function() it("shares fast cache keys except for structural jewel replacements", function() local finder = makeFinder() - local sharedKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + local sharedKey = finder.compute:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { socketNode = { id = 36634 }, occupancy = { isOccupied = false }, }) @@ -652,11 +652,11 @@ describe("RadiusJewelCompute #radius-jewel", function() type = "Jewel", jewelData = { conqueredBy = true }, } - local firstStructuralKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + local firstStructuralKey = finder.compute:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { socketNode = { id = 36634 }, occupancy = { isOccupied = true, item = structuralItem }, }) - local secondStructuralKey = finder:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { + local secondStructuralKey = finder.compute:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", { socketNode = { id = 61419 }, occupancy = { isOccupied = true, item = structuralItem }, }) @@ -686,9 +686,9 @@ describe("RadiusJewelCompute #radius-jewel", function() assert.are.equal(2, #sockets, "expected two free jewel sockets") local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator - local originalCollectCandidates = finder.collectDisconnectedPassiveCandidates - local originalBuildOverride = finder.buildSocketReplacementOverride - local originalCacheKey = finder.getImpossibleEscapePlanCacheKey + 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) @@ -700,21 +700,21 @@ describe("RadiusJewelCompute #radius-jewel", function() return { Life = allocatedCount } end, { Life = 0 } end - finder.collectDisconnectedPassiveCandidates = function() + finder.compute.collectDisconnectedPassiveCandidates = function() return { { id = -101, name = "First" }, { id = -102, name = "Second" }, { id = -103, name = "Third" }, } end - finder.buildSocketReplacementOverride = function(_, _, _, addNodes) + finder.compute.buildSocketReplacementOverride = function(_, _, _, addNodes) return { addNodes = addNodes } end local function countCalculations(cacheKeyFunc) - finder.getImpossibleEscapePlanCacheKey = cacheKeyFunc + finder.compute.getImpossibleEscapePlanCacheKey = cacheKeyFunc calculationCount = 0 - finder:computeImpossibleEscapeSocketImpact(sockets, "Life", { variant }, "fast", { }, nil, 2, nil, true) + finder.compute:computeImpossibleEscapeSocketImpact(sockets, "Life", { variant }, "fast", { }, nil, 2, nil, true) return calculationCount end @@ -723,9 +723,9 @@ describe("RadiusJewelCompute #radius-jewel", function() return string.format("IE|%s|%s|%s", statField, variantName, replacementContext.socketNode.id) end) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator - finder.collectDisconnectedPassiveCandidates = originalCollectCandidates - finder.buildSocketReplacementOverride = originalBuildOverride - finder.getImpossibleEscapePlanCacheKey = originalCacheKey + 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") @@ -737,9 +737,9 @@ describe("RadiusJewelCompute #radius-jewel", function() local sockets = getSockets() local before = snapshotFinderState() - local fastResults, fastBaseline = makeFinder():computeImpossibleEscapeSocketImpact( + local fastResults, fastBaseline = makeFinder().compute:computeImpossibleEscapeSocketImpact( sockets, "Life", { variant }, "fast", { }, nil) - local simulatedResults, simulatedBaseline = makeFinder():computeImpossibleEscapeSocketImpact( + local simulatedResults, simulatedBaseline = makeFinder().compute:computeImpossibleEscapeSocketImpact( sockets, "Life", { variant }, "simulated_greedy", { }, nil) assert.is_true(#fastResults > 0, "expected fast Impossible Escape results") @@ -755,7 +755,7 @@ describe("RadiusJewelCompute #radius-jewel", function() local variant = makeImpossibleEscapeTestVariant() assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant") local maxPoints = 4 - local results, _ = makeFinder():computeImpossibleEscapeSocketImpact( + local results, _ = makeFinder().compute:computeImpossibleEscapeSocketImpact( getSockets(), "Life", { variant }, "simulated_greedy", { }, nil, maxPoints) for _, result in ipairs(results) do local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) @@ -783,21 +783,21 @@ describe("RadiusJewelCompute #radius-jewel", function() finder.getSocketBasePoints = function(_, socket) return socket.pathDist end - finder.collectDisconnectedPassiveCandidates = function() + finder.compute.collectDisconnectedPassiveCandidates = function() return { { id = -101, name = "First" }, { id = -102, name = "Second" }, { id = -103, name = "Third" }, } end - finder.buildSocketReplacementContext = function(_, _, socketId) + finder.compute.buildSocketReplacementContext = function(_, _, socketId) return { socketNode = { id = socketId }, occupancy = occupancyBySocketId[socketId], baselineOutput = { Life = 0 }, } end - finder.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, maxAdditionalNodes, skipPlanSteps) + finder.compute.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, maxAdditionalNodes, skipPlanSteps) local result = { delta = socketNode.id == freeSocket.id and 100 or 90, addedNodeCount = maxAdditionalNodes, @@ -818,7 +818,7 @@ describe("RadiusJewelCompute #radius-jewel", function() build.calcsTab.GetMiscCalculator = function() return function() return { Life = 0 } end, { Life = 0 } end - local results = finder:computeImpossibleEscapeSocketImpact( + local results = finder.compute:computeImpossibleEscapeSocketImpact( { freeSocket, occupiedSocket }, "Life", { variant }, "fast", { }, nil, 5, nil, false) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator @@ -875,9 +875,9 @@ describe("RadiusJewelCompute #radius-jewel", function() local sockets = getTestSockets(threadVariants) local before = snapshotFinderState() - local fastResults, fastBaseline = makeFinder():computeThreadOfHopeSocketImpact( + local fastResults, fastBaseline = makeFinder().compute:computeThreadOfHopeSocketImpact( sockets, "Life", threadVariants, "fast", { }, nil) - local simulatedResults, simulatedBaseline = makeFinder():computeThreadOfHopeSocketImpact( + local simulatedResults, simulatedBaseline = makeFinder().compute:computeThreadOfHopeSocketImpact( sockets, "Life", threadVariants, "simulated_greedy", { }, nil) assert.is_true(#fastResults > 0, "expected fast Thread of Hope results") @@ -897,7 +897,7 @@ describe("RadiusJewelCompute #radius-jewel", function() local threadVariants = getTestVariants() assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") local maxPoints = 4 - local results, _ = makeFinder():computeThreadOfHopeSocketImpact( + local results, _ = makeFinder().compute:computeThreadOfHopeSocketImpact( getTestSockets(threadVariants), "Life", threadVariants, "simulated_greedy", { }, nil, maxPoints) for _, result in ipairs(results) do local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0) @@ -918,16 +918,16 @@ describe("RadiusJewelCompute #radius-jewel", function() finder.getSocketBasePoints = function(_, socket) return socket.pathDist or 0 end - finder.buildSocketReplacementContext = function(_, _, socketId) + finder.compute.buildSocketReplacementContext = function(_, _, socketId) return { socketNode = { id = socketId }, baselineOutput = { Life = 0 }, } end - finder.collectDisconnectedPassiveCandidates = function(_, socketNode) + finder.compute.collectDisconnectedPassiveCandidates = function(_, socketNode) return { { id = socketNode.id * 10, name = "Candidate " .. socketNode.id } } end - finder.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, _, skipPlanSteps) + finder.compute.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, _, skipPlanSteps) local result = { delta = deltaBySocketId[socketNode.id], addedNodeCount = 1, @@ -944,7 +944,7 @@ describe("RadiusJewelCompute #radius-jewel", function() return result end - local results = finder:computeThreadOfHopeSocketImpact( + local results = finder.compute:computeThreadOfHopeSocketImpact( sockets, "Life", { getTestVariants()[1] }, "fast", { }, nil, nil, nil, false) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator return results @@ -1342,7 +1342,7 @@ describe("RadiusJewelCompute #radius-jewel", function() ordinaryAbyssJewel.jewelData.conqueredBy = { conqueror = { type = "Abyss" } } local isAbyssTimelessAllowed = finder:socketMatchesOccupiedMode(socketId, { id = "safe" }) assert.is_false(isAbyssTimelessAllowed) - assert.is_true(finder:socketReplacementChangesPassiveTree({ + assert.is_true(finder.compute:socketReplacementChangesPassiveTree({ occupancy = { isOccupied = true, item = ordinaryAbyssJewel }, }, { type = "Jewel", jewelData = { } })) end) @@ -1428,7 +1428,7 @@ describe("RadiusJewelCompute #radius-jewel", function() local socketId = findUnallocatedSocketId() equipFakeJewel(socketId, "Unnatural Instinct", 1) local finder = makeFinder() - local results = finder:computeSocketImpact({ + local results = finder.compute:computeSocketImpact({ { id = socketId, label = "Test socket", pathDist = 7 }, }, MIGHT_OF_MEEK_RAW_TEXT, "Life", nil, nil, { id = "free" }) diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua index 86f9dc87a5..fd3f00660a 100644 --- a/spec/System/TestRadiusJewelData_spec.lua +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -334,14 +334,14 @@ describe("RadiusJewelData #radius-jewel", function() local finder = makeFinder() local capturedOptions - local originalCollect = finder.collectDisconnectedPassiveCandidates - function finder:collectDisconnectedPassiveCandidates(socketNode, options) + local originalCollect = finder.compute.collectDisconnectedPassiveCandidates + function finder.compute:collectDisconnectedPassiveCandidates(socketNode, options) capturedOptions = options return { } end local sockets = finder:buildJewelSockets(getSmallRadiusIndex()) - finder:computeIntuitiveLeapSocketImpact({ sockets[1] }, "Life", variant, "fast", { }, nil, nil, { id = "all" }) - finder.collectDisconnectedPassiveCandidates = originalCollect + finder.compute:computeIntuitiveLeapSocketImpact({ sockets[1] }, "Life", variant, "fast", { }, nil, nil, { id = "all" }) + finder.compute.collectDisconnectedPassiveCandidates = originalCollect assert.is_not_nil(capturedOptions) assert.is_true(capturedOptions.keystoneOnly) @@ -359,7 +359,7 @@ describe("RadiusJewelData #radius-jewel", function() [massiveRadiusIndex] = { foulbornMassiveKeystone = massiveKeystone }, }, } - local candidates = finder:collectDisconnectedPassiveCandidates(syntheticSocket, capturedOptions) + local candidates = finder.compute:collectDisconnectedPassiveCandidates(syntheticSocket, capturedOptions) assert.are.same({ massiveKeystone }, candidates) data.jewelRadius = previousJewelRadius data.maxJewelRadius = previousMaxJewelRadius @@ -377,7 +377,7 @@ describe("RadiusJewelData #radius-jewel", function() local finder = makeFinder() local computedVariants = { } - function finder:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant) + function finder.compute:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant) computedVariants[#computedVariants + 1] = variant return { { @@ -387,7 +387,7 @@ describe("RadiusJewelData #radius-jewel", function() }, }, 100 end - local results, baseline = finder:computeBestIntuitiveLeapSocketImpact({ { id = "testSocket" } }, "Life", intuitiveVariants, "fast", { }) + local results, baseline = finder.compute:computeBestIntuitiveLeapSocketImpact({ { id = "testSocket" } }, "Life", intuitiveVariants, "fast", { }) assert.are.equal(2, #computedVariants) assert.are.equal(100, baseline) assert.are.equal(1, #results) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 14644a2bf4..7e5f920321 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -131,7 +131,7 @@ describe("RadiusJewelFinder #radius-jewel", function() finder.buildJewelSockets = function() return { { id = 33631, label = "Synthetic socket", pathDist = 1 } } end - finder.computeBestIntuitiveLeapSocketImpact = function(_, sockets, _, variants, methodId, planCache) + finder.compute.computeBestIntuitiveLeapSocketImpact = function(_, sockets, _, variants, methodId, planCache) planCache["result-context-test"] = methodId if yieldDuringCompute then coroutine.yield() @@ -225,7 +225,7 @@ describe("RadiusJewelFinder #radius-jewel", function() popup.controls.resultsList.selIndex = 1 assert.is_true(popup.controls.applyButton.enabled()) - finder.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) + finder.compute.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) return { { socket = sockets[1], @@ -305,7 +305,7 @@ describe("RadiusJewelFinder #radius-jewel", function() finder.buildJewelSockets = function() return { { id = 33631, label = "Synthetic socket", pathDist = 1 } } end - finder.computeBestVariantSocketImpact = function(_, sockets, variants) + finder.compute.computeBestVariantSocketImpact = function(_, sockets, variants) return { { socket = sockets[1], @@ -316,11 +316,11 @@ describe("RadiusJewelFinder #radius-jewel", function() }, }, 100 end - finder.computeSocketImpact = function() return { }, 100 end - finder.computeBestIntuitiveLeapSocketImpact = function() return { }, 100 end - finder.computeThreadOfHopeSocketImpact = function() return { }, 100 end - finder.computeImpossibleEscapeSocketImpact = function() return { }, 100 end - finder.computeSplitPersonalitySocketImpact = function() return { }, 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")) @@ -353,7 +353,7 @@ describe("RadiusJewelFinder #radius-jewel", function() return { { id = targetSocketId, label = "Target socket", pathDist = 1 } } end local computedVariants - finder.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) + finder.compute.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) computedVariants = variants return { { @@ -539,7 +539,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end local observedPartitions = { } - finder.computeBestVariantSocketImpact = function(_, sockets, variants) + finder.compute.computeBestVariantSocketImpact = function(_, sockets, variants) local identity = variants[1].variantIdentity local limitKey = identity.limitKey for _, variant in ipairs(variants) do @@ -557,11 +557,11 @@ describe("RadiusJewelFinder #radius-jewel", function() { socket = sockets[2], variant = variants[1], delta = targetDelta, baseOutput = { }, compareOutput = { } }, }, 100 end - finder.computeSocketImpact = function() return { }, 100 end - finder.computeBestIntuitiveLeapSocketImpact = function() return { }, 100 end - finder.computeThreadOfHopeSocketImpact = function() return { }, 100 end - finder.computeImpossibleEscapeSocketImpact = function() return { }, 100 end - finder.computeSplitPersonalitySocketImpact = function() return { }, 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) @@ -990,7 +990,7 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_true(#popup.controls.jewelVariantSelect.list > 1, "expected at least one selectable keystone variant") local capturedVariants - finder.computeImpossibleEscapeSocketImpact = function(_, _, _, variants) + finder.compute.computeImpossibleEscapeSocketImpact = function(_, _, _, variants) capturedVariants = variants return { }, 0 end diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index a446107eac..d523f575d4 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -5,11 +5,11 @@ -- across all socket/jewel pairs. -- -- Usage: --- local attachCompute = LoadModule("Classes/RadiusJewelCompute") --- attachCompute(RadiusJewelFinderClass, { --- extractTooltipStats, normalizeImpactStat, calculateImpactPercent, --- mustGetUniqueRawText, buildNodeLabelList, getJewelRadiusIndex, +-- local RadiusJewelCompute = LoadModule("Classes/RadiusJewelCompute")({ +-- calculateImpactPercent, mustGetUniqueRawText, buildNodeLabelList, +-- getJewelRadiusIndex, -- }) +-- local compute = RadiusJewelCompute.new(finder) -- local ipairs = ipairs local pairs = pairs @@ -17,15 +17,90 @@ local t_insert = table.insert local t_sort = table.sort local s_format = string.format -return function(Class, helpers) +return function(helpers) + +local RadiusJewelComputeClass = { } +RadiusJewelComputeClass.__index = RadiusJewelComputeClass -local extractTooltipStats = helpers.extractTooltipStats -local normalizeImpactStat = helpers.normalizeImpactStat 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 -- ───────────────────────────────────────────────────────────────────────────── @@ -187,7 +262,7 @@ end -- Class methods -- ───────────────────────────────────────────────────────────────────────────── -function Class:buildSocketReplacementContext(calcFunc, socketId) +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 @@ -211,13 +286,13 @@ function Class:buildSocketReplacementContext(calcFunc, socketId) } end -function Class:socketReplacementChangesPassiveTree(replacementContext, item) +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 Class:getImpossibleEscapePlanCacheKey(statField, variantName, replacementContext) +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 @@ -227,7 +302,7 @@ function Class:getImpossibleEscapePlanCacheKey(statField, variantName, replaceme return cacheKey end -function Class:buildSocketReplacementOverride(replacementContext, item, addNodes) +function RadiusJewelComputeClass:buildSocketReplacementOverride(replacementContext, item, addNodes) local override = { addNodes = addNodes, repSlotName = replacementContext.slotName, @@ -257,7 +332,7 @@ function Class:buildSocketReplacementOverride(replacementContext, item, addNodes return override end -function Class:getSocketDistanceToClassStart(socketId) +function RadiusJewelComputeClass:getSocketDistanceToClassStart(socketId) local spec = self.build.spec local socketNode = spec.nodes[socketId] if not socketNode then @@ -295,7 +370,7 @@ function Class:getSocketDistanceToClassStart(socketId) end -- Candidates are unallocated passives a disconnected-passive jewel may add before scoring. -function Class:collectDisconnectedPassiveCandidates(socketNode, options) +function RadiusJewelComputeClass:collectDisconnectedPassiveCandidates(socketNode, options) local allocNodes = self.build.spec.allocNodes local candidates = { } local seen = { } @@ -334,7 +409,7 @@ function Class:collectDisconnectedPassiveCandidates(socketNode, options) return candidates end -function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, progressLabel, progress, maxAdditionalNodes) +function RadiusJewelComputeClass:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, progressLabel, progress, maxAdditionalNodes) impactStat = normalizeImpactStat(impactStat) local addNodes = { [socketNode] = true } local function calculate(extraNode) @@ -393,7 +468,7 @@ function Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementCont return result end -function Class:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps) +function RadiusJewelComputeClass:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps) impactStat = normalizeImpactStat(impactStat) local jewelOnlyOutput, jewelOnlyValue local function ensureJewelOnly() @@ -477,7 +552,7 @@ function Class:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, return result end -function Class:computeSocketImpact(sockets, rawText, impactStat, progress, maxTotalPoints, occupiedMode) +function RadiusJewelComputeClass:computeSocketImpact(sockets, rawText, impactStat, progress, maxTotalPoints, occupiedMode) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) @@ -512,7 +587,7 @@ function Class:computeSocketImpact(sockets, rawText, impactStat, progress, maxTo return results, realBaseline end -function Class:computeBestVariantSocketImpact(sockets, variants, impactStat, progress, maxTotalPoints, occupiedMode) +function RadiusJewelComputeClass:computeBestVariantSocketImpact(sockets, variants, impactStat, progress, maxTotalPoints, occupiedMode) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) @@ -561,7 +636,7 @@ function Class:computeBestVariantSocketImpact(sockets, variants, impactStat, pro return results, realBaseline end -function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +function RadiusJewelComputeClass:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) @@ -612,7 +687,7 @@ function Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, me return results, realBaseline end -function Class:computeBestIntuitiveLeapSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +function RadiusJewelComputeClass:computeBestIntuitiveLeapSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) if not variants or #variants == 0 then return self:computeIntuitiveLeapSocketImpact(sockets, impactStat, nil, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) end @@ -643,7 +718,7 @@ function Class:computeBestIntuitiveLeapSocketImpact(sockets, impactStat, variant return results, realBaseline end -function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVariants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +function RadiusJewelComputeClass:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVariants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) @@ -715,7 +790,7 @@ function Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVarian return results, realBaseline end -function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants, progress, maxTotalPoints, occupiedMode) +function RadiusJewelComputeClass:computeSplitPersonalitySocketImpact(sockets, impactStat, variants, progress, maxTotalPoints, occupiedMode) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) @@ -984,7 +1059,7 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR end end -function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +function RadiusJewelComputeClass:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) impactStat = normalizeImpactStat(impactStat) local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator() local realBaseline = self:getImpactValue(impactStat, baseOutput) @@ -1010,6 +1085,11 @@ function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants return results, realBaseline end -return buildDisplayedDisconnectedPassivePlans +return { + new = function(finder) + return RadiusJewelComputeClass:new(finder) + end, + buildDisplayedDisconnectedPassivePlans = buildDisplayedDisconnectedPassivePlans, +} -end -- return function(Class, helpers) +end -- return function(helpers) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 498d29dd82..f897d57623 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -16,25 +16,7 @@ local m_abs = math.abs local RadiusJewelData = LoadModule("Classes/RadiusJewelData") local COL_META = RadiusJewelData.COL_META local getJewelRadiusIndex = RadiusJewelData.getJewelRadiusIndex - --- Small output snapshot for stat-comparison tooltips. --- Copies scalar fields and compact Minion output while skipping nested --- calculation and requirement-source tables that retain large object graphs. -local function extractTooltipStats(output) - if not output then return nil end - local out = {} - for k, v in pairs(output) do - local t = type(v) - if t == "number" or t == "string" or t == "boolean" then - out[k] = v - end - end - -- Copy minion stats with the same scalar-only treatment. - if output.Minion then - out.Minion = extractTooltipStats(output.Minion) - end - return out -end +local RadiusJewelCompute -- These sockets have no nearby Keystone. Keep the labels used by the Timeless Jewel finder. local SOCKET_ZONE_NAMES = { @@ -49,49 +31,10 @@ local RadiusJewelFinderClass = newClass("RadiusJewelFinder") function RadiusJewelFinderClass:RadiusJewelFinder(treeTab) self.treeTab = treeTab self.build = treeTab.build + self.compute = RadiusJewelCompute.new(self) return self end -local function normalizeImpactStat(impactStat) - if type(impactStat) == "string" then - return { - field = impactStat, - label = impactStat, - selection = { stat = impactStat, label = impactStat }, - } - elseif impactStat and impactStat.stat and not impactStat.selection then - return { - field = impactStat.stat, - label = impactStat.label, - selection = impactStat, - } - end - return impactStat -end - -function RadiusJewelFinderClass:getImpactValue(impactStat, output) - impactStat = normalizeImpactStat(impactStat) - local selection = impactStat.selection or impactStat - if selection.getValue then - return selection.getValue(output, self.build) - end - local statOutput = output - if statOutput and statOutput.Minion and selection.stat ~= "FullDPS" then - statOutput = statOutput.Minion - end - local value = statOutput and (statOutput[selection.stat] or 0) or 0 - if selection.transform then - value = selection.transform(value) - end - return value -end - -function RadiusJewelFinderClass:calculateImpactDelta(impactStat, baselineOutput, compareOutput) - impactStat = normalizeImpactStat(impactStat) - local selection = impactStat.selection or impactStat - return self.build.calcsTab:CalculatePowerStat(selection, compareOutput, baselineOutput) -end - local function calculateImpactPercent(delta, baseline) local baselineMagnitude = m_abs(baseline) return baselineMagnitude > 0 and (delta / baselineMagnitude * 100) or 0 @@ -134,7 +77,7 @@ function RadiusJewelFinderClass:buildJewelSockets(largeRadiusIndex) end local prefix = allocNodes[socketId] and "# " or "" local pd = socketData.pathDist or 0 - local classStartDist = self:getSocketDistanceToClassStart(socketId) + 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 }) @@ -726,15 +669,13 @@ local function buildNodeLabelList(nodes) return labels end --- Attach compute methods and get the UI helper -local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCompute")(RadiusJewelFinderClass, { - extractTooltipStats = extractTooltipStats, - normalizeImpactStat = normalizeImpactStat, +RadiusJewelCompute = LoadModule("Classes/RadiusJewelCompute")({ calculateImpactPercent = calculateImpactPercent, mustGetUniqueRawText = mustGetUniqueRawText, buildNodeLabelList = buildNodeLabelList, getJewelRadiusIndex = getJewelRadiusIndex, }) +local buildDisplayedDisconnectedPassivePlans = RadiusJewelCompute.buildDisplayedDisconnectedPassivePlans -- ───────────────────────────────────────────────────────────────────────────── -- Best-per-socket allocation @@ -1040,7 +981,7 @@ local function runRadiusJewelFind(self, context, makePreferred) storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, }) elseif isSplitPersonalitySearch then - local score = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id) + local score = socket.classStartDist or self.compute:getSocketDistanceToClassStart(socket.id) t_insert(results, { socket = socket, score = score, @@ -1252,7 +1193,7 @@ local function runRadiusJewelCompute(self, context) local equippedList = self:findEquippedJewelSockets(jewelType, partition.representative) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } computeState.computeContext.removedJewels = removedJewels - local socketResults, partitionBaseline = self:computeBestVariantSocketImpact( + local socketResults, partitionBaseline = self.compute:computeBestVariantSocketImpact( jewelSockets, partition.variants, selectedImpactStat, partitionProgress, selectedMaxPoints, selectedOccupiedMode) baseline = baseline or partitionBaseline @@ -1323,25 +1264,25 @@ local function runRadiusJewelCompute(self, context) computeState.computeContext.removedJewels = removedJewels if jt.name == "Intuitive Leap" then socketResults, baseline = - self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, + self.compute:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) elseif jt.isThread then socketResults, baseline = - self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, + self.compute:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) elseif jt.isImpossibleEscape then socketResults, baseline = - self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, + self.compute:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, jt.variants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) elseif jt.isSplitPersonality then socketResults, baseline = - self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, + self.compute:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, jt.variants or getSplitPersonalityVariants(), typeProgress, selectedMaxPoints, selectedOccupiedMode) else socketResults, baseline = - self:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, + self.compute:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, typeProgress, selectedMaxPoints, selectedOccupiedMode) end self:restoreEquippedJewels(removedJewels) @@ -1407,27 +1348,27 @@ local function runRadiusJewelCompute(self, context) computeState.computeContext.removedJewels = removedJewels if selectedJewelType.name == "Intuitive Leap" then socketResults, baseline = - self:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, + self.compute:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) elseif selectedJewelType.isThread then socketResults, baseline = - self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + self.compute:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) elseif selectedJewelType.isImpossibleEscape then socketResults, baseline = - self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + self.compute:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) elseif selectedJewelType.isSplitPersonality then socketResults, baseline = - self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) + self.compute:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) elseif displayedVariants and #displayedVariants > 0 then if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then itemLabel = selectedVariantGroup.name end socketResults, baseline = - self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + self.compute:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) else local rawText = selectedJewelType.rawText socketResults, baseline = - self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + self.compute:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) end self:restoreEquippedJewels(removedJewels) computeState.computeContext.removedJewels = nil From 55fef0cd5b1cb90ef3643dec565992f19a4138f9 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 22:42:26 +0200 Subject: [PATCH 38/52] Replace positional radius jewel compute arguments Use named request tables for every radius jewel calculation contract and route disconnected-passive methods through one Fast/Simulated selector. Preserve cache, progress, and result behavior while making each call site's intent explicit. --- manifest.xml | 4 +- spec/System/TestRadiusJewelActions_spec.lua | 12 +- spec/System/TestRadiusJewelCompute_spec.lua | 414 ++++++++++++++------ spec/System/TestRadiusJewelData_spec.lua | 25 +- spec/System/TestRadiusJewelFinder_spec.lua | 42 +- src/Classes/RadiusJewelCompute.lua | 291 ++++++++++---- src/Classes/RadiusJewelFinder.lua | 144 +++++-- 7 files changed, 661 insertions(+), 271 deletions(-) diff --git a/manifest.xml b/manifest.xml index 887df8d6b0..9a3b509b23 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,10 +171,10 @@ - + - + diff --git a/spec/System/TestRadiusJewelActions_spec.lua b/spec/System/TestRadiusJewelActions_spec.lua index 8b6f817864..98731e71b3 100644 --- a/spec/System/TestRadiusJewelActions_spec.lua +++ b/spec/System/TestRadiusJewelActions_spec.lua @@ -118,10 +118,10 @@ describe("RadiusJewelFinder actions #radius-jewel", function() finder.buildJewelSockets = function() return { { id = targetSocketId, label = "Target socket", pathDist = 0 } } end - finder.compute.computeThreadOfHopeSocketImpact = function(_, sockets) + finder.compute.computeThreadOfHopeSocketImpact = function(_, request) return { { - socket = sockets[1], + socket = request.sockets[1], variant = targetVariant, delta = 10, baseOutput = { }, @@ -150,10 +150,10 @@ describe("RadiusJewelFinder actions #radius-jewel", function() finder.buildJewelSockets = function() return { { id = targetSocketId, label = "Free target", pathDist = 0 } } end - finder.compute.computeSocketImpact = function(_, sockets) + finder.compute.computeSocketImpact = function(_, request) return { { - socket = sockets[1], + socket = request.sockets[1], delta = 10, baseOutput = { }, compareOutput = { }, @@ -183,10 +183,10 @@ describe("RadiusJewelFinder actions #radius-jewel", function() finder.buildJewelSockets = function() return { { id = targetSocketId, label = "Target socket", pathDist = 0 } } end - finder.compute.computeBestVariantSocketImpact = function(_, sockets) + finder.compute.computeBestVariantSocketImpact = function(_, request) return { { - socket = sockets[1], + socket = request.sockets[1], variant = variant, delta = 10, baseOutput = { }, diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index fc6e46fb94..d5b54d9aaf 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -1,6 +1,7 @@ -- 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 @@ -41,7 +42,11 @@ describe("RadiusJewelCompute #radius-jewel", function() 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, variants, "Life") + 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) @@ -55,7 +60,11 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("keeps comparison snapshots free of nested requirement sources", function() - local results = makeFinder().compute:computeBestVariantSocketImpact(getSockets(), getLightOfMeaningVariants(), "Life") + local results = makeFinder().compute:computeBestVariantSocketImpact({ + sockets = getSockets(), + variants = getLightOfMeaningVariants(), + impactStat = "Life", + }) local nestedRequirementKeys = { "ReqStrFailList", "ReqDexFailList", "ReqIntFailList", "ReqOmniFailList", "ReqStrItem", "ReqDexItem", "ReqIntItem", "ReqOmniItem", @@ -71,14 +80,22 @@ describe("RadiusJewelCompute #radius-jewel", function() it("results are sorted by delta descending", function() local sockets = getSockets() - local results, _ = makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + 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, getLightOfMeaningVariants(), "Life") + 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 @@ -89,7 +106,11 @@ describe("RadiusJewelCompute #radius-jewel", function() it("restores TotalLife after compute", function() local sockets = getSockets() local before = build.calcsTab.mainOutput["Life"] - makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + makeFinder().compute:computeBestVariantSocketImpact({ + sockets = sockets, + variants = getLightOfMeaningVariants(), + impactStat = "Life", + }) local after = build.calcsTab.mainOutput["Life"] assert.are.equal(before, after) end) @@ -97,13 +118,22 @@ describe("RadiusJewelCompute #radius-jewel", function() it("restores socket and item state after compute", function() local sockets = getSockets() local before = snapshotFinderState() - makeFinder().compute:computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life") + 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, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" }) + 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) @@ -135,14 +165,19 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - local results = makeFinder().compute:computeBestVariantSocketImpact({ { - id = socketId, - label = "Historic socket", - pathDist = 0, - } }, { { - name = "Candidate", - rawText = MIGHT_OF_MEEK_RAW_TEXT, - } }, "Life", nil, nil, { id = "all" }) + 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) @@ -181,8 +216,15 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - local results = finder.compute:computeIntuitiveLeapSocketImpact( - { testSocket }, "Life", nil, "fast", { }, nil, 0, { id = "all" }, true) + 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) @@ -205,15 +247,20 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - local results = makeFinder().compute:computeSplitPersonalitySocketImpact({ { - id = socketId, - label = "Historic socket", - classStartDist = splitDistance, - pathDist = 0, - } }, "Life", { { - name = "Dexterity", - rawText = buildSplitPersonalityRawText("+5 to Dexterity"), - } }, nil, nil, { id = "all" }) + 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) @@ -244,15 +291,20 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - makeFinder().compute:computeSplitPersonalitySocketImpact({ { - id = testSocket.id, - label = "Stored Historic socket", - classStartDist = 42, - pathDist = 1, - } }, "Life", { { - name = "Dexterity", - rawText = buildSplitPersonalityRawText("+5 to Dexterity"), - } }, nil, nil, { id = "all" }) + 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) @@ -268,29 +320,31 @@ describe("RadiusJewelCompute #radius-jewel", function() 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 = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + 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 = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "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, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + 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, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + 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, @@ -299,40 +353,41 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("MoM: results sorted by delta descending", function() - local results, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + 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"] - makeFinder().compute:computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "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() - makeFinder().compute:computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT }) assertFinderStateUnchanged(before) end) it("UI: restores TotalLife after compute", function() local before = build.calcsTab.mainOutput["Life"] - makeFinder().compute:computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "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"] - makeFinder().compute:computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "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, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, maxPoints) + 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") @@ -340,8 +395,7 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("occupied sockets (36634, 61419, 41263) are skipped", function() - local results, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + 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], @@ -350,8 +404,10 @@ describe("RadiusJewelCompute #radius-jewel", function() end) it("occupiedMode 'all' includes occupied sockets", function() - local results, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) + 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 @@ -363,27 +419,36 @@ describe("RadiusJewelCompute #radius-jewel", function() it("occupiedMode 'safe' returns at least as many results as 'free'", function() local sockets = getSockets() - local freeResults, _ = makeFinder().compute:computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") - local safeResults, _ = makeFinder().compute:computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "safe" }) + 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, _ = makeFinder().compute:computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life") - local allResults, _ = makeFinder().compute:computeSocketImpact( - sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { id = "all" }) + 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, _ = makeFinder().compute:computeSocketImpact( - getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life") + local results, _ = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT }) local seenSocketIds = {} for _, r in ipairs(results) do assert.is_not_nil(r.socket) @@ -404,10 +469,20 @@ describe("RadiusJewelCompute #radius-jewel", function() 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, _ = makeFinder().compute:computeIntuitiveLeapSocketImpact( - getSockets(), "Life", false, "simulated_greedy", { }, nil, maxPoints) + 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, @@ -426,10 +501,18 @@ describe("RadiusJewelCompute #radius-jewel", function() assert.is_not_nil(targetSocket, "expected at least one socket with path points") local maxPoints = targetSocket.pathDist local sockets = { targetSocket } - local fastResults = makeFinder().compute:computeIntuitiveLeapSocketImpact( - sockets, "Life", false, "fast", { }, nil, maxPoints) - local simulatedResults = makeFinder().compute:computeIntuitiveLeapSocketImpact( - sockets, "Life", false, "simulated_greedy", { }, nil, maxPoints) + 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) @@ -459,10 +542,21 @@ describe("RadiusJewelCompute #radius-jewel", function() 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, { }, { Life = 0 }, 0, socketNode, { }, "Life", - { firstNode, secondNode }, "Combined", { }, nil, nil, 2, true, - previousBestDelta) + 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) @@ -482,6 +576,13 @@ describe("RadiusJewelCompute #radius-jewel", function() { 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() @@ -490,7 +591,7 @@ describe("RadiusJewelCompute #radius-jewel", function() previousDistanceBySocketId[socket.id] = build.spec.nodes[socket.id] and build.spec.nodes[socket.id].distanceToClassStart end - local results, baseline = makeFinder().compute:computeSplitPersonalitySocketImpact(sockets, "Life", variants) + local results, baseline = computeSplit({ sockets = sockets }) assert.is_true(#results > 0, "expected split personality results") assert.is_number(baseline) @@ -508,8 +609,7 @@ describe("RadiusJewelCompute #radius-jewel", function() it("respects max total points", function() local maxPoints = 4 - local results, _ = makeFinder().compute:computeSplitPersonalitySocketImpact( - getSockets(), "Life", variants, nil, maxPoints) + local results, _ = computeSplit({ maxTotalPoints = maxPoints }) for _, result in ipairs(results) do local totalPoints = (result.socket.pathDist or 0) assert.is_true(totalPoints <= maxPoints, @@ -530,12 +630,16 @@ describe("RadiusJewelCompute #radius-jewel", function() return self end local computation = coroutine.create(function() - makeFinder().compute:computeSplitPersonalitySocketImpact({ { - id = socket.id, - label = socket.label, - classStartDist = splitDistance, - pathDist = socket.pathDist, - } }, "Life", variants, progress, nil, { id = "all" }) + 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)) @@ -562,12 +666,15 @@ describe("RadiusJewelCompute #radius-jewel", function() end local ok, err = pcall(function() - makeFinder().compute:computeSplitPersonalitySocketImpact({ { - id = socket.id, - label = socket.label, - classStartDist = (previousDistance or 0) + 100, - pathDist = socket.pathDist, - } }, "Life", variants, nil, nil, { id = "all" }) + computeSplit({ + sockets = { { + id = socket.id, + label = socket.label, + classStartDist = (previousDistance or 0) + 100, + pathDist = socket.pathDist, + } }, + occupiedMode = { id = "all" }, + }) end) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator @@ -612,14 +719,19 @@ describe("RadiusJewelCompute #radius-jewel", function() end, { Life = 0 } end - makeFinder().compute:computeBestVariantSocketImpact({ { - id = socketId, - label = "Cluster socket", - pathDist = 0, - } }, { { - name = "Candidate", - rawText = MIGHT_OF_MEEK_RAW_TEXT, - } }, "Life", nil, nil, { id = "all" }) + 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") @@ -642,6 +754,13 @@ describe("RadiusJewelCompute #radius-jewel", function() 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", { @@ -714,7 +833,13 @@ describe("RadiusJewelCompute #radius-jewel", function() local function countCalculations(cacheKeyFunc) finder.compute.getImpossibleEscapePlanCacheKey = cacheKeyFunc calculationCount = 0 - finder.compute:computeImpossibleEscapeSocketImpact(sockets, "Life", { variant }, "fast", { }, nil, 2, nil, true) + computeImpossibleEscape(finder.compute, { + sockets = sockets, + variants = { variant }, + methodId = "fast", + maxTotalPoints = 2, + skipPlanSteps = true, + }) return calculationCount end @@ -737,10 +862,16 @@ describe("RadiusJewelCompute #radius-jewel", function() local sockets = getSockets() local before = snapshotFinderState() - local fastResults, fastBaseline = makeFinder().compute:computeImpossibleEscapeSocketImpact( - sockets, "Life", { variant }, "fast", { }, nil) - local simulatedResults, simulatedBaseline = makeFinder().compute:computeImpossibleEscapeSocketImpact( - sockets, "Life", { variant }, "simulated_greedy", { }, nil) + 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") @@ -755,8 +886,11 @@ describe("RadiusJewelCompute #radius-jewel", function() local variant = makeImpossibleEscapeTestVariant() assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant") local maxPoints = 4 - local results, _ = makeFinder().compute:computeImpossibleEscapeSocketImpact( - getSockets(), "Life", { variant }, "simulated_greedy", { }, nil, maxPoints) + 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, @@ -797,18 +931,19 @@ describe("RadiusJewelCompute #radius-jewel", function() baselineOutput = { Life = 0 }, } end - finder.compute.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, maxAdditionalNodes, skipPlanSteps) + finder.compute.computeDisconnectedPassiveFastPlan = function(_, request) + local socketNode = request.socketNode local result = { delta = socketNode.id == freeSocket.id and 100 or 90, - addedNodeCount = maxAdditionalNodes, + addedNodeCount = request.maxAdditionalNodes, resultNodes = { socketNode.id * 10 }, resultNodeLabels = { "Plan for " .. socketNode.id }, baseOutput = { Life = 0 }, compareOutput = { Life = socketNode.id }, detailText = "plan-" .. socketNode.id, - variantLabel = variantLabel, + variantLabel = request.variantLabel, } - if not skipPlanSteps then + if not request.skipPlanSteps then result.planSteps = { { detailText = result.detailText } } end return result @@ -818,8 +953,13 @@ describe("RadiusJewelCompute #radius-jewel", function() build.calcsTab.GetMiscCalculator = function() return function() return { Life = 0 } end, { Life = 0 } end - local results = finder.compute:computeImpossibleEscapeSocketImpact( - { freeSocket, occupiedSocket }, "Life", { variant }, "fast", { }, nil, 5, nil, false) + local results = computeImpossibleEscape(finder.compute, { + sockets = { freeSocket, occupiedSocket }, + variants = { variant }, + methodId = "fast", + maxTotalPoints = 5, + skipPlanSteps = false, + }) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator local resultBySocketId = { } @@ -869,16 +1009,28 @@ describe("RadiusJewelCompute #radius-jewel", function() 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 = makeFinder().compute:computeThreadOfHopeSocketImpact( - sockets, "Life", threadVariants, "fast", { }, nil) - local simulatedResults, simulatedBaseline = makeFinder().compute:computeThreadOfHopeSocketImpact( - sockets, "Life", threadVariants, "simulated_greedy", { }, nil) + 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") @@ -897,8 +1049,12 @@ describe("RadiusJewelCompute #radius-jewel", function() local threadVariants = getTestVariants() assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants") local maxPoints = 4 - local results, _ = makeFinder().compute:computeThreadOfHopeSocketImpact( - getTestSockets(threadVariants), "Life", threadVariants, "simulated_greedy", { }, nil, maxPoints) + 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, @@ -927,7 +1083,8 @@ describe("RadiusJewelCompute #radius-jewel", function() finder.compute.collectDisconnectedPassiveCandidates = function(_, socketNode) return { { id = socketNode.id * 10, name = "Candidate " .. socketNode.id } } end - finder.compute.computeDisconnectedPassiveFastPlan = function(_, _, _, _, _, socketNode, _, _, _, variantLabel, _, _, _, _, skipPlanSteps) + finder.compute.computeDisconnectedPassiveFastPlan = function(_, request) + local socketNode = request.socketNode local result = { delta = deltaBySocketId[socketNode.id], addedNodeCount = 1, @@ -936,16 +1093,20 @@ describe("RadiusJewelCompute #radius-jewel", function() baseOutput = { Life = 0 }, compareOutput = { Life = deltaBySocketId[socketNode.id] }, detailText = "plan-" .. socketNode.id, - variantLabel = variantLabel, + variantLabel = request.variantLabel, } - if not skipPlanSteps then + if not request.skipPlanSteps then result.planSteps = { { detailText = result.detailText } } end return result end - local results = finder.compute:computeThreadOfHopeSocketImpact( - sockets, "Life", { getTestVariants()[1] }, "fast", { }, nil, nil, nil, false) + local results = computeThread(finder.compute, { + sockets = sockets, + variants = { getTestVariants()[1] }, + methodId = "fast", + skipPlanSteps = false, + }) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator return results end @@ -1429,8 +1590,13 @@ describe("RadiusJewelCompute #radius-jewel", function() equipFakeJewel(socketId, "Unnatural Instinct", 1) local finder = makeFinder() local results = finder.compute:computeSocketImpact({ - { id = socketId, label = "Test socket", pathDist = 7 }, - }, MIGHT_OF_MEEK_RAW_TEXT, "Life", nil, nil, { id = "free" }) + 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) diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua index fd3f00660a..2cbe4fcd86 100644 --- a/spec/System/TestRadiusJewelData_spec.lua +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -340,7 +340,14 @@ describe("RadiusJewelData #radius-jewel", function() return { } end local sockets = finder:buildJewelSockets(getSmallRadiusIndex()) - finder.compute:computeIntuitiveLeapSocketImpact({ sockets[1] }, "Life", variant, "fast", { }, nil, nil, { id = "all" }) + 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) @@ -377,17 +384,23 @@ describe("RadiusJewelData #radius-jewel", function() local finder = makeFinder() local computedVariants = { } - function finder.compute:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant) - computedVariants[#computedVariants + 1] = variant + function finder.compute:computeIntuitiveLeapSocketImpact(request) + computedVariants[#computedVariants + 1] = request.variant return { { - socket = sockets[1], - delta = variant.isFoulborn and 2 or 1, + socket = request.sockets[1], + delta = request.variant.isFoulborn and 2 or 1, addedNodeCount = 0, }, }, 100 end - local results, baseline = finder.compute:computeBestIntuitiveLeapSocketImpact({ { id = "testSocket" } }, "Life", intuitiveVariants, "fast", { }) + 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) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 7e5f920321..9b97082ef7 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -131,16 +131,16 @@ describe("RadiusJewelFinder #radius-jewel", function() finder.buildJewelSockets = function() return { { id = 33631, label = "Synthetic socket", pathDist = 1 } } end - finder.compute.computeBestIntuitiveLeapSocketImpact = function(_, sockets, _, variants, methodId, planCache) - planCache["result-context-test"] = methodId + finder.compute.computeBestIntuitiveLeapSocketImpact = function(_, request) + request.planCache["result-context-test"] = request.methodId if yieldDuringCompute then coroutine.yield() end computeCompleted = true return { { - socket = sockets[1], - variant = variants[1], + socket = request.sockets[1], + variant = request.variants[1], delta = 1, addedNodeCount = 0, baseOutput = { }, @@ -225,11 +225,11 @@ describe("RadiusJewelFinder #radius-jewel", function() popup.controls.resultsList.selIndex = 1 assert.is_true(popup.controls.applyButton.enabled()) - finder.compute.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) + finder.compute.computeThreadOfHopeSocketImpact = function(_, request) return { { - socket = sockets[1], - variant = variants[1], + socket = request.sockets[1], + variant = request.variants[1], delta = 1, addedNodeCount = 0, baseOutput = { }, @@ -305,11 +305,11 @@ describe("RadiusJewelFinder #radius-jewel", function() finder.buildJewelSockets = function() return { { id = 33631, label = "Synthetic socket", pathDist = 1 } } end - finder.compute.computeBestVariantSocketImpact = function(_, sockets, variants) + finder.compute.computeBestVariantSocketImpact = function(_, request) return { { - socket = sockets[1], - variant = variants[1], + socket = request.sockets[1], + variant = request.variants[1], delta = 1, baseOutput = { }, compareOutput = { }, @@ -353,12 +353,12 @@ describe("RadiusJewelFinder #radius-jewel", function() return { { id = targetSocketId, label = "Target socket", pathDist = 1 } } end local computedVariants - finder.compute.computeThreadOfHopeSocketImpact = function(_, sockets, _, variants) - computedVariants = variants + finder.compute.computeThreadOfHopeSocketImpact = function(_, request) + computedVariants = request.variants return { { - socket = sockets[1], - variant = variants[1], + socket = request.sockets[1], + variant = request.variants[1], delta = 1, addedNodeCount = 0, baseOutput = { }, @@ -539,10 +539,10 @@ describe("RadiusJewelFinder #radius-jewel", function() end local observedPartitions = { } - finder.compute.computeBestVariantSocketImpact = function(_, sockets, variants) - local identity = variants[1].variantIdentity + finder.compute.computeBestVariantSocketImpact = function(_, request) + local identity = request.variants[1].variantIdentity local limitKey = identity.limitKey - for _, variant in ipairs(variants) do + for _, variant in ipairs(request.variants) do assert.are.equal(limitKey, variant.variantIdentity.limitKey, "each compute call should contain one canonical limit partition") end @@ -553,8 +553,8 @@ describe("RadiusJewelFinder #radius-jewel", function() local sourceDelta = limitKey == "The Red Nightmare" and 10 or 1 local targetDelta = limitKey == "The Red Nightmare" and 15 or 2 return { - { socket = sockets[1], variant = variants[1], delta = sourceDelta, baseOutput = { }, compareOutput = { } }, - { socket = sockets[2], variant = variants[1], delta = targetDelta, baseOutput = { }, compareOutput = { } }, + { 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 @@ -990,8 +990,8 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_true(#popup.controls.jewelVariantSelect.list > 1, "expected at least one selectable keystone variant") local capturedVariants - finder.compute.computeImpossibleEscapeSocketImpact = function(_, _, _, variants) - capturedVariants = variants + finder.compute.computeImpossibleEscapeSocketImpact = function(_, request) + capturedVariants = request.variants return { }, 0 end popup.controls.computeButton:Click() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index d523f575d4..e54958cc42 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -118,6 +118,14 @@ local function progressChild(progress, startFraction, spanFraction) 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 @@ -409,7 +417,19 @@ function RadiusJewelComputeClass:collectDisconnectedPassiveCandidates(socketNode return candidates end -function RadiusJewelComputeClass:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, progressLabel, progress, maxAdditionalNodes) +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) @@ -468,7 +488,21 @@ function RadiusJewelComputeClass:computeDisconnectedPassiveSimulatedPlan(calcFun return result end -function RadiusJewelComputeClass:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, baseOutput, baseValue, socketNode, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps) +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() @@ -552,7 +586,20 @@ function RadiusJewelComputeClass:computeDisconnectedPassiveFastPlan(calcFunc, re return result end -function RadiusJewelComputeClass:computeSocketImpact(sockets, rawText, impactStat, progress, maxTotalPoints, occupiedMode) +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) @@ -587,7 +634,13 @@ function RadiusJewelComputeClass:computeSocketImpact(sockets, rawText, impactSta return results, realBaseline end -function RadiusJewelComputeClass:computeBestVariantSocketImpact(sockets, variants, impactStat, progress, maxTotalPoints, occupiedMode) +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) @@ -636,7 +689,16 @@ function RadiusJewelComputeClass:computeBestVariantSocketImpact(sockets, variant return results, realBaseline end -function RadiusJewelComputeClass:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +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) @@ -665,14 +727,28 @@ function RadiusJewelComputeClass:computeIntuitiveLeapSocketImpact(sockets, impac if #candidates > 0 then local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput) - local result + local deltaCache if methodId == "fast" then local cacheKey = s_format("IL|%s|%s|%s", statField, variantKey, socket.id) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, nil, planCache[cacheKey], socket.label, socketProgress, maxAdditionalNodes, skipPlanSteps) - else - result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, nil, socket.label, socketProgress, maxAdditionalNodes) + 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 @@ -687,17 +763,20 @@ function RadiusJewelComputeClass:computeIntuitiveLeapSocketImpact(sockets, impac return results, realBaseline end -function RadiusJewelComputeClass:computeBestIntuitiveLeapSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +function RadiusJewelComputeClass:computeBestIntuitiveLeapSocketImpact(request) + local variants = request.variants if not variants or #variants == 0 then - return self:computeIntuitiveLeapSocketImpact(sockets, impactStat, nil, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) + return self:computeIntuitiveLeapSocketImpact(request) end local bestBySocket = { } local realBaseline local variantCount = #variants for variantIndex, variant in ipairs(variants) do - local variantProgress = progressChild(progress, (variantIndex - 1) / variantCount, 1 / variantCount) - local results, baseline = self:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, - variantProgress, maxTotalPoints, occupiedMode, skipPlanSteps) + 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 @@ -718,7 +797,16 @@ function RadiusJewelComputeClass:computeBestIntuitiveLeapSocketImpact(sockets, i return results, realBaseline end -function RadiusJewelComputeClass:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVariants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +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) @@ -753,14 +841,29 @@ function RadiusJewelComputeClass:computeThreadOfHopeSocketImpact(sockets, impact }) if #candidates > 0 then local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil - local result + local deltaCache if methodId == "fast" then local cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, planCache[cacheKey], socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes, skipPlanSteps) - else - result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext, replacementContext.baselineOutput, socketBaseline, socketNode, item, impactStat, candidates, ringLabel, socket.label .. " | " .. ringLabel, variantProgress, maxAdditionalNodes) + 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 @@ -790,7 +893,13 @@ function RadiusJewelComputeClass:computeThreadOfHopeSocketImpact(sockets, impact return results, realBaseline end -function RadiusJewelComputeClass:computeSplitPersonalitySocketImpact(sockets, impactStat, variants, progress, maxTotalPoints, occupiedMode) +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) @@ -923,7 +1032,16 @@ local function groupImpossibleEscapeSockets(self, sockets, maxTotalPoints, occup return groupedOrder end -local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, variants, variantDataByName, methodId, impactStat, statField, calcFunc, planCache, progress) +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 @@ -939,42 +1057,29 @@ local function computeImpossibleEscapeRepresentativeResults(self, groupedOrder, local variantData = variantDataByName[variant.name] if variantData then local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil - local result + local deltaCache if methodId == "fast" then local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, variant.name, replacementContext) planCache[cacheKey] = planCache[cacheKey] or { } - result = self:computeDisconnectedPassiveFastPlan( - calcFunc, - replacementContext, - replacementContext.baselineOutput, - socketBaseline, - representativeSocketNode, - variantData.item, - impactStat, - variantData.candidates, - variant.name, - planCache[cacheKey], - variant.name, - planProgress, - maxAdditionalNodes, - true - ) - else - result = self:computeDisconnectedPassiveSimulatedPlan( - calcFunc, - replacementContext, - replacementContext.baselineOutput, - socketBaseline, - representativeSocketNode, - variantData.item, - impactStat, - variantData.candidates, - variant.name, - variant.name, - planProgress, - maxAdditionalNodes - ) + 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 @@ -1018,7 +1123,15 @@ local function fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGro return results end -local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestResultByGroupKey, variantDataByName, impactStat, statField, calcFunc, planCache) +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] @@ -1028,22 +1141,20 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR 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, - replacementContext, - replacementContext.baselineOutput, - socketBaseline, - replacementContext.socketNode, - variantData.item, - impactStat, - variantData.candidates, - bestResult.variant.name, - planCache[cacheKey], - nil, - nil, - maxAdditionalNodes, - false - ) + 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 @@ -1059,7 +1170,16 @@ local function addImpossibleEscapePlanDetails(self, results, groupedOrder, bestR end end -function RadiusJewelComputeClass:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps) +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) @@ -1070,16 +1190,29 @@ function RadiusJewelComputeClass:computeImpossibleEscapeSocketImpact(sockets, im if #groupedOrder == 0 then return { }, realBaseline end - local bestResultByGroupKey = computeImpossibleEscapeRepresentativeResults( - self, groupedOrder, variants, variantDataByName, methodId, impactStat, - statField, calcFunc, planCache, progress - ) + 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, groupedOrder, bestResultByGroupKey, variantDataByName, - impactStat, statField, calcFunc, planCache - ) + addImpossibleEscapePlanDetails(self, { + results = results, + groupedOrder = groupedOrder, + bestResultByGroupKey = bestResultByGroupKey, + variantDataByName = variantDataByName, + impactStat = impactStat, + statField = statField, + calcFunc = calcFunc, + planCache = planCache, + }) end return results, realBaseline diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index f897d57623..dc39bff71d 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -1193,9 +1193,14 @@ local function runRadiusJewelCompute(self, context) local equippedList = self:findEquippedJewelSockets(jewelType, partition.representative) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } computeState.computeContext.removedJewels = removedJewels - local socketResults, partitionBaseline = self.compute:computeBestVariantSocketImpact( - jewelSockets, partition.variants, selectedImpactStat, - partitionProgress, selectedMaxPoints, selectedOccupiedMode) + local socketResults, partitionBaseline = self.compute:computeBestVariantSocketImpact({ + sockets = jewelSockets, + variants = partition.variants, + impactStat = selectedImpactStat, + progress = partitionProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) baseline = baseline or partitionBaseline self:restoreEquippedJewels(removedJewels) computeState.computeContext.removedJewels = nil @@ -1263,27 +1268,59 @@ local function runRadiusJewelCompute(self, context) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } computeState.computeContext.removedJewels = removedJewels if jt.name == "Intuitive Leap" then - socketResults, baseline = - self.compute:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, jt.variants, - computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + socketResults, baseline = self.compute:computeBestIntuitiveLeapSocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = jt.variants, + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = typeProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + skipPlanSteps = true, + }) elseif jt.isThread then - socketResults, baseline = - self.compute:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, - computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + socketResults, baseline = self.compute:computeThreadOfHopeSocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = threadVariants, + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = typeProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + skipPlanSteps = true, + }) elseif jt.isImpossibleEscape then - socketResults, baseline = - self.compute:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, - jt.variants or getImpossibleEscapeVariants(), - computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true) + socketResults, baseline = self.compute:computeImpossibleEscapeSocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = jt.variants or getImpossibleEscapeVariants(), + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = typeProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + skipPlanSteps = true, + }) elseif jt.isSplitPersonality then - socketResults, baseline = - self.compute:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, - jt.variants or getSplitPersonalityVariants(), - typeProgress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeSplitPersonalitySocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = jt.variants or getSplitPersonalityVariants(), + progress = typeProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) else - socketResults, baseline = - self.compute:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat, - typeProgress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeSocketImpact({ + sockets = jewelSockets, + rawText = jt.rawText, + impactStat = selectedImpactStat, + progress = typeProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) end self:restoreEquippedJewels(removedJewels) computeState.computeContext.removedJewels = nil @@ -1347,28 +1384,69 @@ local function runRadiusJewelCompute(self, context) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } computeState.computeContext.removedJewels = removedJewels if selectedJewelType.name == "Intuitive Leap" then - socketResults, baseline = - self.compute:computeBestIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, displayedVariants, computeMethod.id, - finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeBestIntuitiveLeapSocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = displayedVariants, + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = progress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) elseif selectedJewelType.isThread then - socketResults, baseline = - self.compute:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeThreadOfHopeSocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = threadVariants, + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = progress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) elseif selectedJewelType.isImpossibleEscape then - socketResults, baseline = - self.compute:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeImpossibleEscapeSocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = displayedVariants or getImpossibleEscapeVariants(), + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = progress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) elseif selectedJewelType.isSplitPersonality then - socketResults, baseline = - self.compute:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeSplitPersonalitySocketImpact({ + sockets = jewelSockets, + impactStat = selectedImpactStat, + variants = displayedVariants or getSplitPersonalityVariants(), + progress = progress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) elseif displayedVariants and #displayedVariants > 0 then if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then itemLabel = selectedVariantGroup.name end - socketResults, baseline = - self.compute:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeBestVariantSocketImpact({ + sockets = jewelSockets, + variants = displayedVariants, + impactStat = selectedImpactStat, + progress = progress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) else local rawText = selectedJewelType.rawText - socketResults, baseline = - self.compute:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode) + socketResults, baseline = self.compute:computeSocketImpact({ + sockets = jewelSockets, + rawText = rawText, + impactStat = selectedImpactStat, + progress = progress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + }) end self:restoreEquippedJewels(removedJewels) computeState.computeContext.removedJewels = nil From a340b4c722938b1eb5f2c2f4c9755acc087ed21e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 23:15:41 +0200 Subject: [PATCH 39/52] Route radius jewels through explicit strategies Give each jewel type one Find and Compute strategy instead of repeating name and flag dispatch across the popup. Centralize shared request construction and node collection while preserving variant limits, filters, progress, cache behavior, and All jewels results. --- manifest.xml | 4 +- spec/System/TestRadiusJewelData_spec.lua | 14 + spec/System/TestRadiusJewelFinder_spec.lua | 60 +++ src/Classes/RadiusJewelData.lua | 25 + src/Classes/RadiusJewelFinder.lua | 589 ++++++++++----------- 5 files changed, 393 insertions(+), 299 deletions(-) diff --git a/manifest.xml b/manifest.xml index 9a3b509b23..6000fa47a8 100644 --- a/manifest.xml +++ b/manifest.xml @@ -172,9 +172,9 @@ - + - + diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua index 2cbe4fcd86..648da0bec3 100644 --- a/spec/System/TestRadiusJewelData_spec.lua +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -55,6 +55,20 @@ describe("RadiusJewelData #radius-jewel", function() describe("buildJewelTypes", function() + 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) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 9b97082ef7..d0362444ad 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -167,6 +167,66 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_false(popup.controls.applyButton.enabled(), message) 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") diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 0fe93736c8..73558bd6a6 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -22,6 +22,16 @@ 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 -- ───────────────────────────────────────────────────────────────────────────── @@ -789,6 +799,7 @@ M.jewelPreviewFn = jewelPreviewFn function M.buildJewelTypes() local mightOfTheMeek = { name = "Might of the Meek", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = getUniqueRadiusIndex("Might of the Meek"), scoreLabel = "alloc small passives", hasCompute = true, @@ -806,6 +817,7 @@ function M.buildJewelTypes() local inspiredLearning = { name = "Inspired Learning", + strategy = JEWEL_STRATEGY.RADIUS, hasCompute = true, radiusIndex = getUniqueRadiusIndex("Inspired Learning"), scoreLabel = "alloc notables", @@ -824,6 +836,7 @@ function M.buildJewelTypes() local unnaturalInstinct = { name = "Unnatural Instinct", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = getUniqueRadiusIndex("Unnatural Instinct"), scoreLabel = "unalloc small - alloc small", hasCompute = true, @@ -843,6 +856,7 @@ function M.buildJewelTypes() local lioneyesFall = { name = "Lioneye's Fall", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = getUniqueRadiusIndex("Lioneye's Fall"), scoreLabel = "alloc passives", hasCompute = true, @@ -853,6 +867,7 @@ function M.buildJewelTypes() local intuitiveLeap = { name = "Intuitive Leap", + strategy = JEWEL_STRATEGY.INTUITIVE_LEAP, radiusIndex = getUniqueRadiusIndex("Intuitive Leap"), scoreLabel = "unalloc passives", hasCompute = true, @@ -914,6 +929,7 @@ function M.buildJewelTypes() local jewelTypes = { } t_insert(jewelTypes, { name = "The Light of Meaning", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = lightOfMeaningVariants[1] and lightOfMeaningVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, @@ -925,6 +941,7 @@ function M.buildJewelTypes() t_insert(jewelTypes, inspiredLearning) t_insert(jewelTypes, { name = "Anatomical Knowledge", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = getUniqueRadiusIndex("Anatomical Knowledge"), scoreLabel = "alloc passives", hasCompute = true, @@ -934,6 +951,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Tempered & Transcendent", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = temperedTranscendentVariants[1] and temperedTranscendentVariants[1].radiusIndex, scoreLabel = "attr in radius", hasCompute = true, @@ -946,6 +964,7 @@ function M.buildJewelTypes() t_insert(jewelTypes, intuitiveLeap) t_insert(jewelTypes, { name = "Impossible Escape", + strategy = JEWEL_STRATEGY.IMPOSSIBLE_ESCAPE, isImpossibleEscape = true, isSocketIndependent = true, scoreLabel = "unalloc notable/keystone near keystone", @@ -956,6 +975,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Split Personality", + strategy = JEWEL_STRATEGY.SPLIT_PERSONALITY, isSplitPersonality = true, scoreLabel = "dist to start", hasCompute = true, @@ -966,6 +986,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Stat Conversion", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = statConversionVariants[1] and statConversionVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, @@ -974,6 +995,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Attribute Conversion", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = attributeConversionVariants[1] and attributeConversionVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, @@ -982,6 +1004,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Combat Focus", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = combatFocusVariants[1] and combatFocusVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, @@ -990,6 +1013,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Dreams & Nightmares", + strategy = JEWEL_STRATEGY.RADIUS, radiusIndex = dreamsVariants[1] and dreamsVariants[1].radiusIndex, scoreLabel = "alloc passives", hasCompute = true, @@ -998,6 +1022,7 @@ function M.buildJewelTypes() }) t_insert(jewelTypes, { name = "Thread of Hope", + strategy = JEWEL_STRATEGY.THREAD_OF_HOPE, isThread = true, scoreLabel = "unalloc notable/keystone in ring", hasCompute = true, diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index dc39bff71d..4719e09929 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -44,6 +44,7 @@ end 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 jewelPreviewFn = RadiusJewelData.jewelPreviewFn local buildJewelTypes = RadiusJewelData.buildJewelTypes local makeVariantDropdownEntry = RadiusJewelData.makeVariantDropdownEntry @@ -854,6 +855,229 @@ local function buildRadiusJewelPopupSetup(self) } 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, + topNodes = { }, + 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, + keepBestAllJewelsRowPerSocket = true, + }, + [JEWEL_STRATEGY.THREAD_OF_HOPE] = { + findSocket = findThreadSocket, + compute = computeThreadOfHopeStrategy, + 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, + appendMatchCount = true, + keepBestAllJewelsRowPerSocket = true, + getDetailNodeId = function(request, variant) + local keystoneNode = variant and request.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] = { }, +} + +local function getJewelStrategy(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, makePreferred) local controls = context.controls local treeData = context.treeData @@ -881,144 +1105,35 @@ local function runRadiusJewelFind(self, context, makePreferred) controls.statusLabel.label = "^7Searching..." local ok, err = pcall(function() local allocNodes = self.build.spec.allocNodes - local isThreadBestVariantSearch = selectedJewelType.isThread == true - local isImpossibleEscapeBestVariantSearch = selectedJewelType.isImpossibleEscape == true - local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true - local radiusIndex - local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil - if isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then - radiusIndex = nil - elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then - radiusIndex = selectedJewelVariant.radiusIndex - else - radiusIndex = selectedJewelType.radiusIndex - end - - if not isThreadBestVariantSearch and not isImpossibleEscapeBestVariantSearch and not isSplitPersonalitySearch - and not radiusIndex then - return - end - - local results = { } - local impossibleEscapeBestResult - if isImpossibleEscapeBestVariantSearch then - local variants = getSelectedVariants() or selectedJewelType.variants or { } - for _, variant in ipairs(variants) do - local keystoneNode = treeData.keystoneMap[variant.keystoneName] - local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex and keystoneNode.nodesInRadius[smallRadiusIndex] - if nodes then - local score = selectedJewelType.score(nodes, allocNodes) or 0 - local topNodes = { } - for _, n in pairs(nodes) do - if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then - t_insert(topNodes, { - label = n.dn or n.name or "Unknown", - nodeId = n.id, - }) - end - end - t_sort(topNodes, function(a, b) return a.label < b.label end) - local candidate = { - score = score, - topNodes = topNodes, - variant = variant, - detailText = variant.name, - } - if not impossibleEscapeBestResult - or candidate.score > impossibleEscapeBestResult.score - or (candidate.score == impossibleEscapeBestResult.score and candidate.variant.name < impossibleEscapeBestResult.variant.name) then - impossibleEscapeBestResult = candidate - end - end + local strategy = getJewelStrategy(selectedJewelType) + 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] - if socketAllowed and socketNode and (socketNode.nodesInRadius or isSplitPersonalitySearch) then - if isThreadBestVariantSearch then - local bestThreadResult - for _, threadVariant in ipairs(threadVariants) do - local nodes = socketNode.nodesInRadius[threadVariant.radiusIndex] - if nodes then - local score = selectedJewelType.score(nodes, allocNodes) or 0 - local topNodes = { } - for _, n in pairs(nodes) do - if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then - t_insert(topNodes, { - label = n.dn or n.name or "Unknown", - nodeId = n.id, - }) - end - end - t_sort(topNodes, function(a, b) return a.label < b.label end) - local candidate = { - socket = socket, - score = score, - topNodes = topNodes, - variant = threadVariant, - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - } - if not bestThreadResult - or candidate.score > bestThreadResult.score - or (candidate.score == bestThreadResult.score and candidate.variant.radiusIndex < bestThreadResult.variant.radiusIndex) then - bestThreadResult = candidate - end - end - end - if bestThreadResult then - t_insert(results, bestThreadResult) - end - elseif isImpossibleEscapeBestVariantSearch and impossibleEscapeBestResult then - t_insert(results, { - socket = socket, - score = impossibleEscapeBestResult.score, - topNodes = impossibleEscapeBestResult.topNodes, - variant = impossibleEscapeBestResult.variant, - detailText = impossibleEscapeBestResult.detailText, - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - }) - elseif isSplitPersonalitySearch then - local score = socket.classStartDist or self.compute:getSocketDistanceToClassStart(socket.id) - t_insert(results, { - socket = socket, - score = score, - topNodes = { }, - detailText = s_format("dist to start %d", score), - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - }) - else - local nodes = socketNode.nodesInRadius[radiusIndex] - - if nodes then - local scoreFn = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.score) - or selectedJewelType.score - local score = scoreFn(nodes, allocNodes) - local detailBuilder = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.detailBuilder) - or selectedJewelType.detailBuilder - local topNodes = { } - for _, n in pairs(nodes) do - if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then - t_insert(topNodes, { - label = n.dn or n.name or "Unknown", - nodeId = n.id, - }) - end - end - t_sort(topNodes, function(a, b) return a.label < b.label end) - t_insert(results, { - socket = socket, - score = score or 0, - topNodes = topNodes, - variant = selectedJewelVariant, - detailText = detailBuilder and detailBuilder(nodes, allocNodes) or nil, - replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil, - storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil, - }) - end + if socketAllowed 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 @@ -1049,14 +1164,10 @@ local function runRadiusJewelFind(self, context, makePreferred) local detailText = r.detailText if not detailText or detailText == "" then detailText = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel - elseif #topStr > 0 and (isThreadBestVariantSearch or isImpossibleEscapeBestVariantSearch) then + elseif #topStr > 0 and strategy.appendMatchCount then detailText = detailText .. s_format(" | %d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") end - local detailNodeId = nil - if isImpossibleEscapeBestVariantSearch and r.variant and r.variant.keystoneName then - local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] - detailNodeId = keystoneNode and keystoneNode.id or nil - end + local detailNodeId = strategy.getDetailNodeId and strategy.getDetailNodeId(findRequest, r.variant) or nil local targetIdentity = r.variant and r.variant.variantIdentity or selectedJewelVariant and selectedJewelVariant.variantIdentity or selectedJewelType.variantIdentity @@ -1077,7 +1188,7 @@ local function runRadiusJewelFind(self, context, makePreferred) score = r.score or 0, scorePerPoint = scorePerPoint, sortValue = sortValue, - variantLabel = r.variant and (isThreadBestVariantSearch and (r.variant.ringLabel or (r.variant.name .. " Ring")) + variantLabel = r.variant and (strategy.formatVariantLabel and strategy.formatVariantLabel(r.variant) or r.variant.dropdownLabel or r.variant.name) or "", detailText = detailText, detailNodeId = detailNodeId, @@ -1091,19 +1202,13 @@ local function runRadiusJewelFind(self, context, makePreferred) }) end stampResultRows(rows, resultContextKey) - controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)") + local resultMode = strategy.resultMode or "find" + controls.resultsList:SetMode(resultMode, rows, COL_META .. "(no results)") local elapsed = formatElapsed(searchStartTime) - local threadLabel = #threadVariants == 1 - and ("Thread of Hope (" .. (threadVariants[1].ringLabel or (threadVariants[1].name .. " Ring")) .. ")") - or "Thread of Hope (Any ring)" - controls.statusLabel.label = (isThreadBestVariantSearch - and s_format("^7%s | %d | score/pt", threadLabel, #results) - or isImpossibleEscapeBestVariantSearch - and s_format("^7Impossible Escape | %d | score/pt", #results) - or isSplitPersonalitySearch - and s_format("^7Split Personality | %d | score/pt", #results) + controls.statusLabel.label = (strategy.formatFindStatus + and strategy.formatFindStatus(findRequest, #results) or s_format("^7%d results | score/pt", #results)) .. elapsed - saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred, resultContextKey) + saveResultCache("find", resultMode, rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred, resultContextKey) if not makePreferred then restoreCachedResults() end @@ -1169,6 +1274,20 @@ local function runRadiusJewelCompute(self, context) local statLabel = selectedImpactStat.label local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil) local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil + local function makeComputeRequest(variants, computeProgress, skipPlanSteps) + return { + sockets = jewelSockets, + variants = variants, + threadVariants = threadVariants, + impactStat = selectedImpactStat, + methodId = computeMethod.id, + planCache = finderState.disconnectedPassivePlanCache, + progress = computeProgress, + maxTotalPoints = selectedMaxPoints, + occupiedMode = selectedOccupiedMode, + skipPlanSteps = skipPlanSteps, + } + end local function computeVariantPartitionRows(jewelType, variants, computeProgress) local partitions = { } local partitionByLimitKey = { } @@ -1193,14 +1312,8 @@ local function runRadiusJewelCompute(self, context) local equippedList = self:findEquippedJewelSockets(jewelType, partition.representative) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } computeState.computeContext.removedJewels = removedJewels - local socketResults, partitionBaseline = self.compute:computeBestVariantSocketImpact({ - sockets = jewelSockets, - variants = partition.variants, - impactStat = selectedImpactStat, - progress = partitionProgress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) + local socketResults, partitionBaseline = computeJewelType(self, jewelType, + makeComputeRequest(partition.variants, partitionProgress)) baseline = baseline or partitionBaseline self:restoreEquippedJewels(removedJewels) computeState.computeContext.removedJewels = nil @@ -1255,73 +1368,18 @@ local function runRadiusJewelCompute(self, context) local typeProgress = wrapProgress(rawChild) local socketResults, baseline local typeRows - local isStandardVariantType = jt.variants and #jt.variants > 0 - and jt.name ~= "Intuitive Leap" - and not jt.isThread - and not jt.isImpossibleEscape - and not jt.isSplitPersonality + local strategy = getJewelStrategy(jt) + local useVariantPartitions = strategy.usesVariantPartitions + and jt.variants and #jt.variants > 0 - if isStandardVariantType then + 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 - if jt.name == "Intuitive Leap" then - socketResults, baseline = self.compute:computeBestIntuitiveLeapSocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = jt.variants, - methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, - progress = typeProgress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - skipPlanSteps = true, - }) - elseif jt.isThread then - socketResults, baseline = self.compute:computeThreadOfHopeSocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = threadVariants, - methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, - progress = typeProgress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - skipPlanSteps = true, - }) - elseif jt.isImpossibleEscape then - socketResults, baseline = self.compute:computeImpossibleEscapeSocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = jt.variants or getImpossibleEscapeVariants(), - methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, - progress = typeProgress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - skipPlanSteps = true, - }) - elseif jt.isSplitPersonality then - socketResults, baseline = self.compute:computeSplitPersonalitySocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = jt.variants or getSplitPersonalityVariants(), - progress = typeProgress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - else - socketResults, baseline = self.compute:computeSocketImpact({ - sockets = jewelSockets, - rawText = jt.rawText, - impactStat = selectedImpactStat, - progress = typeProgress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - end + socketResults, baseline = computeJewelType(self, jt, + makeComputeRequest(jt.variants, typeProgress, true)) self:restoreEquippedJewels(removedJewels) computeState.computeContext.removedJewels = nil typeRows = buildComputeRows(jt, socketResults, baseline, equippedList) @@ -1329,8 +1387,7 @@ local function runRadiusJewelCompute(self, context) globalBaseline = globalBaseline or baseline - -- For disconnected-passive types: keep only the best row per socket - if jt.name == "Intuitive Leap" or jt.isThread or jt.isImpossibleEscape then + if strategy.keepBestAllJewelsRowPerSocket then local bestBySocket = { } for _, row in ipairs(typeRows) do local ex = bestBySocket[row.socketId] @@ -1360,19 +1417,15 @@ local function runRadiusJewelCompute(self, context) saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true, resultContextKey) else local displayedVariants = getSelectedVariants() - local itemLabel = selectedJewelType.name - if selectedJewelType.isThread then - itemLabel = #threadVariants == 1 - and (itemLabel .. " (" .. (threadVariants[1].ringLabel or (threadVariants[1].name .. " Ring")) .. ")") - or (itemLabel .. " (Any ring)") - end + local strategy = getJewelStrategy(selectedJewelType) + local computeRequest = makeComputeRequest(displayedVariants, progress) + local itemLabel = strategy.formatComputeLabel + and strategy.formatComputeLabel(selectedJewelType, computeRequest) + or selectedJewelType.name local socketResults, baseline local rows - local useVariantPartitions = displayedVariants and #displayedVariants > 1 - and selectedJewelType.name ~= "Intuitive Leap" - and not selectedJewelType.isThread - and not selectedJewelType.isImpossibleEscape - and not selectedJewelType.isSplitPersonality + 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 @@ -1383,71 +1436,12 @@ local function runRadiusJewelCompute(self, context) local equippedList = self:findEquippedJewelSockets(selectedJewelType, equippedVariant) local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { } computeState.computeContext.removedJewels = removedJewels - if selectedJewelType.name == "Intuitive Leap" then - socketResults, baseline = self.compute:computeBestIntuitiveLeapSocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = displayedVariants, - methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, - progress = progress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - elseif selectedJewelType.isThread then - socketResults, baseline = self.compute:computeThreadOfHopeSocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = threadVariants, - methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, - progress = progress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - elseif selectedJewelType.isImpossibleEscape then - socketResults, baseline = self.compute:computeImpossibleEscapeSocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = displayedVariants or getImpossibleEscapeVariants(), - methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, - progress = progress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - elseif selectedJewelType.isSplitPersonality then - socketResults, baseline = self.compute:computeSplitPersonalitySocketImpact({ - sockets = jewelSockets, - impactStat = selectedImpactStat, - variants = displayedVariants or getSplitPersonalityVariants(), - progress = progress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - elseif displayedVariants and #displayedVariants > 0 then - if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then - itemLabel = selectedVariantGroup.name - end - socketResults, baseline = self.compute:computeBestVariantSocketImpact({ - sockets = jewelSockets, - variants = displayedVariants, - impactStat = selectedImpactStat, - progress = progress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) - else - local rawText = selectedJewelType.rawText - socketResults, baseline = self.compute:computeSocketImpact({ - sockets = jewelSockets, - rawText = rawText, - impactStat = selectedImpactStat, - progress = progress, - maxTotalPoints = selectedMaxPoints, - occupiedMode = selectedOccupiedMode, - }) + 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) @@ -2145,6 +2139,7 @@ local function buildRadiusJewelPopupContext(self) end) t_insert(activeJewelTypes, 1, { name = "All jewels", + strategy = JEWEL_STRATEGY.ALL_JEWELS, isAllJewels = true, hasCompute = true, }) From bbc6e5e98bce2d9b0cc7492cceed86fa615c4678 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 23:37:10 +0200 Subject: [PATCH 40/52] Extract radius jewel result state Move cached result restoration, view preference, row context, and applicability checks behind one focused owner. Keep selection-dependent key construction in the popup so criteria and result storage no longer share one nested implementation. --- manifest.xml | 2 +- src/Classes/RadiusJewelFinder.lua | 204 ++++++++++++++++++------------ 2 files changed, 126 insertions(+), 80 deletions(-) diff --git a/manifest.xml b/manifest.xml index 6000fa47a8..720d351a0b 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 4719e09929..4f863dd6a6 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -771,6 +771,106 @@ local function synchronizeResultCaches(build, finderState) end end +local RadiusJewelResultState = { } +RadiusJewelResultState.__index = RadiusJewelResultState + +function RadiusJewelResultState:new(finder, finderState, computeState, controls) + return setmetatable({ + finder = finder, + finderState = finderState, + 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:restore(resultContextKey, isAllJewels, allJewelsViewId) + local preferredView = self.finderState.resultViewByKey[resultContextKey] + local findCache = not isAllJewels and self.finderState.findCache[resultContextKey] or nil + local computeCache = self.finderState.computeCache[resultContextKey] + local cache = preferredView == "compute" and computeCache or findCache + if not cache and preferredView == "compute" then + cache = findCache + elseif not cache and preferredView == "find" then + cache = computeCache + end + cache = cache or findCache or computeCache + if not cache or cache.resultContextKey ~= resultContextKey then + return false + end + + local rows = copyTableSafe(cache.rows, false, true) + if cache.mode == "computeSocketAll" then + self.computeState.lastComputeAllRows = rows + self.computeState.lastComputeAllResultContextKey = resultContextKey + if allJewelsViewId == "bestPerSocket" then + rows = self.finder:filterBestPerSocket(rows) + end + else + self.computeState.lastComputeAllRows = nil + self.computeState.lastComputeAllResultContextKey = nil + end + self.controls.resultsList:SetMode(cache.mode, rows, cache.defaultText) + self.controls.statusLabel.label = cache.statusLabel or self.controls.statusLabel.label + return true +end + +function RadiusJewelResultState:save(request) + if request.resultContextKey ~= request.currentResultContextKey then + return false + end + local targetCache = request.viewName == "compute" and self.finderState.computeCache or self.finderState.findCache + targetCache[request.resultContextKey] = { + mode = request.mode, + rows = copyTableSafe(request.rows, false, true), + defaultText = request.defaultText, + statusLabel = request.statusLabel, + resultContextKey = request.resultContextKey, + } + if request.makePreferred then + self.finderState.resultViewByKey[request.resultContextKey] = request.viewName + end + return true +end + +function RadiusJewelResultState:clear(isAllJewels) + self.computeState.lastComputeAllRows = nil + self.computeState.lastComputeAllResultContextKey = nil + local message = isAllJewels + and (COL_META .. "Click Compute to rank all jewels") + or (COL_META .. "Click Find to search") + self.controls.statusLabel.label = message + self.controls.resultsList:SetMode("message", { }, message) +end + +function RadiusJewelResultState:rememberVisibleView(resultContextKey) + local mode = self.controls.resultsList.mode + local viewName = (mode == "find" or mode == "findThread") and "find" + or (mode == "computeSocket" or mode == "computeSocketAll") and "compute" + if not viewName then + return + end + local cache + if viewName == "compute" then + cache = self.finderState.computeCache[resultContextKey] + else + cache = self.finderState.findCache[resultContextKey] + end + if cache and cache.resultContextKey == resultContextKey then + self.finderState.resultViewByKey[resultContextKey] = viewName + end +end + +function RadiusJewelResultState:isApplicable(row, currentResultContextKey) + return row ~= nil and row.actionPlan ~= nil and row.resultContextKey == currentResultContextKey + and isActionPlanCurrent(self.finder.build, row.actionPlan) +end + local function buildRadiusJewelPopupSetup(self) local treeData = self.build.spec.tree local radiusIndexByLabel = { @@ -1092,7 +1192,7 @@ local function runRadiusJewelFind(self, context, makePreferred) local formatElapsed = context.formatElapsed local restoreCachedResults = context.restoreCachedResults local saveResultCache = context.saveResultCache - local stampResultRows = context.stampResultRows + local setResultContext = context.setResultContext local showAllJewelsComputePrompt = context.showAllJewelsComputePrompt local searchStartTime = GetTime() @@ -1201,7 +1301,7 @@ local function runRadiusJewelFind(self, context, makePreferred) applyRawText = targetRawText, }) end - stampResultRows(rows, resultContextKey) + setResultContext(rows, resultContextKey) local resultMode = strategy.resultMode or "find" controls.resultsList:SetMode(resultMode, rows, COL_META .. "(no results)") local elapsed = formatElapsed(searchStartTime) @@ -1250,7 +1350,7 @@ local function runRadiusJewelCompute(self, context) local formatComputeStatus = context.formatComputeStatus local formatElapsed = context.formatElapsed local saveResultCache = context.saveResultCache - local stampResultRows = context.stampResultRows + local setResultContext = context.setResultContext local getSelectedVariants = context.getSelectedVariants local hasVariantGroups = context.hasVariantGroups local selectedVariantGroup = context.selectedVariantGroup @@ -1407,7 +1507,7 @@ local function runRadiusJewelCompute(self, context) end globalBaseline = globalBaseline or 0 - stampResultRows(allRows, resultContextKey) + setResultContext(allRows, resultContextKey) computeState.lastComputeAllRows = allRows computeState.lastComputeAllResultContextKey = resultContextKey local displayRows = getSelectedAllJewelsView().id == "bestPerSocket" @@ -1446,7 +1546,7 @@ local function runRadiusJewelCompute(self, context) computeState.computeContext.removedJewels = nil rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList) end - stampResultRows(rows, resultContextKey) + setResultContext(rows, resultContextKey) controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true, resultContextKey) @@ -1541,6 +1641,7 @@ local function buildRadiusJewelPopupContext(self) local allJewelsViewLabels = setup.allJewelsViewLabels local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1] local computeState = { } + local resultState = RadiusJewelResultState:new(self, finderState, computeState, controls) local suppressFinderStateSave = false local runFind @@ -1599,98 +1700,43 @@ local function buildRadiusJewelPopupContext(self) }, "|") end - local function stampResultRows(rows, resultContextKey) - for _, row in ipairs(rows or { }) do - row.resultContextKey = resultContextKey - end + local function setResultContext(rows, resultContextKey) + resultState:setResultContext(rows, resultContextKey) end local function restoreCachedResults(resultContextKey) local key = resultContextKey or getResultContextKey() - local preferredView = finderState.resultViewByKey[key] - local allowFindCache = not (selectedJewelType and selectedJewelType.isAllJewels) - local findCache = allowFindCache and finderState.findCache[key] or nil - local computeCache = finderState.computeCache[key] - local cache = preferredView == "compute" and computeCache or findCache - if not cache and preferredView == "compute" then - cache = findCache - elseif not cache and preferredView == "find" then - cache = computeCache - end - if not cache then - cache = findCache or computeCache - end - if not cache then - return false - end - if cache.resultContextKey ~= key then - return false - end - local rows = copyTableSafe(cache.rows, false, true) - if cache.mode == "computeSocketAll" then - computeState.lastComputeAllRows = rows - computeState.lastComputeAllResultContextKey = key - if selectedAllJewelsView.id == "bestPerSocket" then - rows = self:filterBestPerSocket(rows) - end - else - computeState.lastComputeAllRows = nil - computeState.lastComputeAllResultContextKey = nil - end - controls.resultsList:SetMode(cache.mode, rows, cache.defaultText) - controls.statusLabel.label = cache.statusLabel or controls.statusLabel.label - return true + return resultState:restore(key, + selectedJewelType and selectedJewelType.isAllJewels, + selectedAllJewelsView.id) end local function saveResultCache(viewName, mode, rows, defaultText, statusLabel, makePreferred, resultContextKey) local key = resultContextKey or getResultContextKey() - if key ~= getResultContextKey() then - return false - end - local targetCache = viewName == "compute" and finderState.computeCache or finderState.findCache - targetCache[key] = { + return resultState:save({ + viewName = viewName, mode = mode, - rows = copyTableSafe(rows, false, true), + rows = rows, defaultText = defaultText, statusLabel = statusLabel, + makePreferred = makePreferred, resultContextKey = key, - } - if makePreferred then - finderState.resultViewByKey[key] = viewName - end - return true + currentResultContextKey = getResultContextKey(), + }) end local function clearResultsForContext() - computeState.lastComputeAllRows = nil - computeState.lastComputeAllResultContextKey = nil - local message = selectedJewelType and selectedJewelType.isAllJewels - and (COL_META .. "Click Compute to rank all jewels") - or (COL_META .. "Click Find to search") - controls.statusLabel.label = message - controls.resultsList:SetMode("message", { }, message) + resultState:clear(selectedJewelType and selectedJewelType.isAllJewels) end local function saveVisibleResultView(resultContextKey) - local mode = controls.resultsList.mode - local viewName = (mode == "find" or mode == "findThread") and "find" - or (mode == "computeSocket" or mode == "computeSocketAll") and "compute" - if not viewName then - return - end - local cache - if viewName == "compute" then - cache = finderState.computeCache[resultContextKey] - else - cache = finderState.findCache[resultContextKey] - end - if cache and cache.resultContextKey == resultContextKey then - finderState.resultViewByKey[resultContextKey] = viewName - end + resultState:rememberVisibleView(resultContextKey) end local function isResultContextCurrent(resultContextKey) return resultContextKey == getResultContextKey() end local function isResultApplicable(row) - return row ~= nil and row.actionPlan ~= nil and isResultContextCurrent(row.resultContextKey) - and isActionPlanCurrent(self.build, row.actionPlan) + if not row or not row.actionPlan then + return false + end + return resultState:isApplicable(row, getResultContextKey()) end local function onCriteriaChanged(updateCriteria) cancelCompute() @@ -2678,7 +2724,7 @@ local function buildRadiusJewelPopupContext(self) formatComputeStatus = formatComputeStatus, formatElapsed = formatElapsed, saveResultCache = saveResultCache, - stampResultRows = stampResultRows, + setResultContext = setResultContext, getSelectedVariants = getSelectedVariants, hasVariantGroups = hasVariantGroups, selectedVariantGroup = selectedVariantGroup, @@ -2734,7 +2780,7 @@ local function buildRadiusJewelPopupContext(self) formatElapsed = formatElapsed, restoreCachedResults = restoreCachedResults, saveResultCache = saveResultCache, - stampResultRows = stampResultRows, + setResultContext = setResultContext, showAllJewelsComputePrompt = showAllJewelsComputePrompt, }, makePreferred) end From 57070086a98bfc22503829b25179a6b70f630fba Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 18 Aug 2026 00:23:33 +0200 Subject: [PATCH 41/52] Extract radius jewel result actions Move selection, placement controls, confirmation, and action tooltips behind a focused popup owner. Keep result details and the validated action planning and execution contract unchanged. --- manifest.xml | 2 +- src/Classes/RadiusJewelFinder.lua | 345 +++++++++++++++++------------- 2 files changed, 193 insertions(+), 154 deletions(-) diff --git a/manifest.xml b/manifest.xml index 720d351a0b..e21cd9b925 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 4f863dd6a6..62d8de7092 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -871,6 +871,191 @@ function RadiusJewelResultState:isApplicable(row, currentResultContextKey) and isActionPlanCurrent(self.finder.build, 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 findCanonicalBuildItem(self.finder.build.itemsTab, row.actionPlan.targetCanonicalKey) +end + +function RadiusJewelResultActions:execute(row, resultContextKey) + if self:isApplicable(row) and row.resultContextKey == resultContextKey then + self.finder:executeActionPlan(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: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 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 plan.sourceItemId then + local source = plan.sourceSocketId and plan.sourceSocketLabel or "Items" + tooltip:AddLine(16, "^7Source: ^x33FF77" .. plan.sourceItemLabel .. " ^7in " .. source) + else + tooltip:AddLine(16, "^7Source: ^x33FF77New jewel") + 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 function buildRadiusJewelPopupSetup(self) local treeData = self.build.spec.tree local radiusIndexByLabel = { @@ -1321,13 +1506,6 @@ local function runRadiusJewelFind(self, context, makePreferred) end end -local function applyRadiusJewelResult(self, row, resultContextKey) - if not row or not row.actionPlan or row.resultContextKey ~= resultContextKey then - return - end - self:executeActionPlan(row.actionPlan) -end - local function runRadiusJewelCompute(self, context) local controls = context.controls local computeState = context.computeState @@ -1630,7 +1808,6 @@ local function buildRadiusJewelPopupContext(self) local variantGroupOptions = { { name = "All", value = ALL_VARIANT_GROUPS_VALUE } } local selectedVariantGroup = variantGroupOptions[1] local controls = { } - local applySelectedResult local jtLabels = { } local tvLabels = setup.threadVariantLabels local socketViewer = setup.socketViewer @@ -1732,12 +1909,6 @@ local function buildRadiusJewelPopupContext(self) local function isResultContextCurrent(resultContextKey) return resultContextKey == getResultContextKey() end - local function isResultApplicable(row) - if not row or not row.actionPlan then - return false - end - return resultState:isApplicable(row, getResultContextKey()) - end local function onCriteriaChanged(updateCriteria) cancelCompute() local previousResultContextKey = getResultContextKey() @@ -2154,15 +2325,11 @@ local function buildRadiusJewelPopupContext(self) controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, contentTopY, leftPanelWidth, resultListBottomY - contentTopY }, self.build, socketViewer) controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped - controls.resultsList.OnSelect = function(_, _, row) + local resultActions = RadiusJewelResultActions:new(self, resultState, controls.resultsList, getResultContextKey) + resultActions:bindSelection(function(row) updateResultDetails(row) updatePreview(row) - end - controls.resultsList.OnSelClick = function(_, index, value, doubleClick) - if doubleClick then - applySelectedResult() - end - end + end) controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") local function rebuildJewelTypeDropdown() @@ -2795,138 +2962,10 @@ local function buildRadiusJewelPopupContext(self) tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.") end - local actionLabels = { - equip = "Equip", - move = "Move", - replace = "Replace", - equipped = "Equipped", - } - local function getSelectedActionRow() - local idx = controls.resultsList.selIndex - return idx and controls.resultsList.list[idx] or nil - end - local function getMatchingBuildItem(row) - if not row or not row.actionPlan then - return nil - end - return findCanonicalBuildItem(self.build.itemsTab, row.actionPlan.targetCanonicalKey) - end - local function executeSelectedResult(row, resultContextKey) - if isResultApplicable(row) then - applyRadiusJewelResult(self, row, resultContextKey) - end - end - applySelectedResult = function() - local row = getSelectedActionRow() - local resultContextKey = getResultContextKey() - if not isResultApplicable(row) then - return - end - local plan = row.actionPlan - if not plan.targetSocketAllocated then - local actionLabel = actionLabels[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() - executeSelectedResult(row, resultContextKey) - end) - return - end - executeSelectedResult(row, resultContextKey) - end - local function addSelectedResultToBuild() - local row = getSelectedActionRow() - if isResultApplicable(row) then - self:executeAddToBuildPlan(row.actionPlan) - end - end - controls.addToBuildButton = new("ButtonControl"):ButtonControl(BL, { rightPanelX, bottomButtonY, 100, buttonHeight }, function() - local existingItem = getMatchingBuildItem(getSelectedActionRow()) - return existingItem and "In build" or "Add to build" - end, addSelectedResultToBuild) - controls.addToBuildButton.enabled = function() - local row = getSelectedActionRow() - return isResultApplicable(row) and not getMatchingBuildItem(row) - end - controls.addToBuildButton.tooltipFunc = function(tooltip) - local row = getSelectedActionRow() - 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 = getMatchingBuildItem(row) - if existingItem then - local location = existingSocketId and getSocketLabel(existingSocket, existingSocketId) or "Items" - tooltip:AddLine(16, "^8" .. itemName .. " is already in this build in " .. location .. ".") - if existingSocketId and self.build.spec.allocNodes[existingSocketId] == nil then - tooltip:AddLine(16, "^xFFAA33That socket is unallocated and hidden from the Items panel.") - end - return - end - if not isResultApplicable(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 - controls.applyButton = new("ButtonControl"):ButtonControl(BL, { rightPanelX + 110, bottomButtonY, 80, buttonHeight }, function() - local row = getSelectedActionRow() - local kind = row and row.actionPlan and row.actionPlan.kind - return actionLabels[kind] or "Equip" - end, applySelectedResult) - controls.applyButton.enabled = function() - local row = getSelectedActionRow() - return isResultApplicable(row) and row.actionPlan.kind ~= "equipped" - end - controls.applyButton.tooltipFunc = function(tooltip) - local row = getSelectedActionRow() - if row and row.actionPlan and not isResultApplicable(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" .. actionLabels[plan.kind] .. " ^x33FF77" .. itemName .. " ^7in ^x33FF77" .. plan.targetSocketLabel) - if plan.sourceItemId then - local source = plan.sourceSocketId and plan.sourceSocketLabel or "Items" - tooltip:AddLine(16, "^7Source: ^x33FF77" .. plan.sourceItemLabel .. " ^7in " .. source) - else - tooltip:AddLine(16, "^7Source: ^x33FF77New jewel") - 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 " .. actionLabels[plan.kind]:lower() .. " it.") - 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 From 5635663156e0039e20c874e0518e2580c670fbfc Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 18 Aug 2026 20:39:23 +0200 Subject: [PATCH 42/52] Extract radius jewel result presentation Move preview, detail rendering, and selection updates behind a focused popup owner while preserving the validated result and action contracts. --- manifest.xml | 2 +- src/Classes/RadiusJewelFinder.lua | 497 ++++++++++++++++-------------- 2 files changed, 275 insertions(+), 224 deletions(-) diff --git a/manifest.xml b/manifest.xml index e21cd9b925..0c061ea660 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 62d8de7092..3771c3ecb9 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -1056,6 +1056,256 @@ function RadiusJewelResultActions:createControls(anchor, addToBuildRect, applyRe 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, + previewListData = { }, + resultDetailListData = { }, + compactPreview = false, + }, 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 = jewelPreviewFn[jewelType.name] + if not fn then + return nil + end + local selectedTypeMatches = request.selectedJewelType + and request.selectedJewelType.name == jewelType.name + if jewelType.isThread 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 + if not (jewelType.isThread or jewelType.variants) then + local lines = self:buildPreviewLines(request) + if type(lines) ~= "table" then + return nil + end + return lines + end + local fn = jewelPreviewFn[jewelType.name] + local lines = fn and fn() or nil + if type(lines) ~= "table" then + return nil + end + if jewelType.isThread 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 + local note + if jewelType.isThread then + note = "Multiple ring sizes available" + else + note = "Multiple variants available" + end + t_insert(genericLines, { height = 16, [1] = COL_META .. note }) + return genericLines +end + +function RadiusJewelResultPresentation:getPreviewListHeight() + return self.compactPreview and 48 or 180 +end + +function RadiusJewelResultPresentation:getResultDetailLabelY() + return self.layout.y + self:getPreviewListHeight() + 6 +end + +function RadiusJewelResultPresentation:getResultDetailListY() + return self:getResultDetailLabelY() + 18 +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." }) + return + end + t_insert(self.resultDetailListData, { height = 16, [1] = "^7Socket: " .. (row.socketLabel or "(n/a)") }) + if row.variantLabel and row.variantLabel ~= "" then + t_insert(self.resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel }) + end + local actionPlan = row.actionPlan + local action = actionPlan and actionPlan.kind or row.action + if actionPlan and not actionPlan.targetSocketAllocated then + t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33This socket is unallocated and hidden from the Items panel." }) + t_insert(self.resultDetailListData, { height = 16, [1] = "^8Add to build keeps the jewel in the item list; placement uses the hidden socket." }) + end + 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 + if actionPlan and actionPlan.sourceItemId then + local sourceText = actionPlan.sourceSocketId + and (actionPlan.sourceItemLabel .. " in " .. actionPlan.sourceSocketLabel) + or (actionPlan.sourceItemLabel .. " from Items") + t_insert(self.resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77" .. sourceText }) + elseif actionPlan then + t_insert(self.resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77New " .. (actionPlan.targetIdentity.uniqueName or "jewel") }) + end + if action == "equipped" then + t_insert(self.resultDetailListData, { height = 16, [1] = "^8Already equipped" }) + elseif action == "move" then + t_insert(self.resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" }) + if actionPlan and actionPlan.replacedTargetLabel then + t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. actionPlan.replacedTargetLabel, item = replacementItem }) + end + elseif action == "replace" then + t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" }) + local replacementLabel = actionPlan and actionPlan.replacedTargetLabel + or row.replacedItemLabel or row.storedUnallocatedItemLabel or "?" + t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. replacementLabel, item = replacementItem }) + else + t_insert(self.resultDetailListData, { height = 16, [1] = "^2Use free socket" }) + end + if row.detailText and row.detailText ~= "" then + t_insert(self.resultDetailListData, { height = 16, [1] = "^7" .. row.detailText }) + end + local nodeEntries = row.resultNodes or row.topNodes + if nodeEntries and #nodeEntries > 0 then + t_insert(self.resultDetailListData, { height = 6, [1] = "" }) + t_insert(self.resultDetailListData, { + height = 16, + [1] = row.resultNodes and s_format("^7Passives to allocate (%d):", #nodeEntries) + or s_format("^7Passives in range (%d):", #nodeEntries), + }) + for _, nodeEntry in ipairs(nodeEntries) do + t_insert(self.resultDetailListData, { + height = 16, + [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)), + nodeId = nodeEntry.nodeId, + }) + end + else + t_insert(self.resultDetailListData, { height = 6, [1] = "" }) + t_insert(self.resultDetailListData, { height = 16, [1] = row.resultNodes and (COL_META .. "No passives to allocate") or (COL_META .. "No passives in range") }) + end + t_insert(self.resultDetailListData, { height = 16, [1] = "^8Passive allocations are not applied automatically." }) +end + +function RadiusJewelResultPresentation:addPreviewLines(lines) + if type(lines) ~= "table" then + return false + end + for _, line in ipairs(lines) do + t_insert(self.previewListData, line) + end + return #lines > 0 +end + +function RadiusJewelResultPresentation:updatePreview(request, row) + wipeTable(self.previewListData) + self.compactPreview = false + local selectedJewelType = request.selectedJewelType + if not selectedJewelType then + t_insert(self.previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) + return + end + if selectedJewelType.isAllJewels then + local mode = self.controls.resultsList and self.controls.resultsList.mode + local selectedPreviewLines + if mode == "computeSocketAll" then + local previewRow = row or self.controls.resultsList.selValue + selectedPreviewLines = previewRow and previewRow.itemTooltipLines + if previewRow and self:addPreviewLines(previewRow.itemTooltipLines) then + return + end + end + self.compactPreview = not selectedPreviewLines + t_insert(self.previewListData, { height = 16, [1] = "^7Evaluate every jewel type." }) + if request.selectedAllJewelsView.id == "bestPerSocket" then + t_insert(self.previewListData, { height = 16, [1] = "^7Best jewel per socket." }) + else + t_insert(self.previewListData, { height = 16, [1] = "^7Sorted globally by %/Pt." }) + end + return + end + local lines = self:buildPreviewLines(request) + if type(lines) ~= "table" then + t_insert(self.previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) + return + end + self:addPreviewLines(lines) +end + +function RadiusJewelResultPresentation:selectResult(row, previewRequest) + self:updateResultDetails(row) + self:updatePreview(previewRequest, row) +end + +function RadiusJewelResultPresentation:createControls(socketViewer) + local controls = self.controls + local layout = self.layout + controls.previewList = new("TextListControl"):TextListControl(layout.anchor, + { layout.x, layout.y, layout.width, 180 }, + { { x = 0, align = "LEFT" }, { x = 210, align = "LEFT" } }, self.previewListData) + controls.previewList.height = function() return self:getPreviewListHeight() end + controls.previewList.shown = function() + return not (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) + end + controls.resultDetailLabel = new("LabelControl"):LabelControl(layout.anchor, + { layout.x, 256, 0, 16 }, "^7Details:") + controls.resultDetailLabel.y = function() return self:getResultDetailLabelY() end + controls.resultDetailList = new("RadiusJewelDetailListControl"):RadiusJewelDetailListControl(layout.anchor, + { layout.x, 274, layout.width, 156 }, + { { x = 0, align = "LEFT" } }, self.resultDetailListData, self.finder.build, socketViewer) + controls.resultDetailList.y = function() return self:getResultDetailListY() end + controls.resultDetailList.height = function() + return layout.bottomY - self:getResultDetailListY() + end +end + local function buildRadiusJewelPopupSetup(self) local treeData = self.build.spec.tree local radiusIndexByLabel = { @@ -2039,81 +2289,6 @@ local function buildRadiusJewelPopupContext(self) return selectedThreadVariant and { selectedThreadVariant } or threadVariants end - local function buildPreviewLinesForJewelType(jewelType, previewVariantOverride) - if not jewelType then - return nil - end - local fn = jewelPreviewFn[jewelType.name] - if not fn then - return nil - end - local selectedTypeMatches = selectedJewelType and selectedJewelType.name == jewelType.name - if jewelType.isThread then - local threadVariant = previewVariantOverride or selectedThreadVariant - return fn(threadVariant and threadVariant.name) - elseif jewelType.variants then - local previewVariant = previewVariantOverride - if not previewVariant then - previewVariant = selectedTypeMatches and selectedJewelVariant or nil - end - if not previewVariant and not selectedTypeMatches then - previewVariant = jewelType.variants[1] - end - return fn(previewVariant) - end - return fn() - end - - local function addPreviewLinesToTooltip(tooltip, lines) - if type(lines) ~= "table" then - return - end - tooltip:Clear(true) - for _, line in ipairs(lines) do - tooltip:AddLine(line.height or 16, line[1], line.font) - end - end - - local function buildGenericTypeTooltipLinesForJewelType(jewelType) - if not jewelType then - return nil - end - if not (jewelType.isThread or jewelType.variants) then - local lines = buildPreviewLinesForJewelType(jewelType) - if type(lines) ~= "table" then - return nil - end - return lines - end - local fn = jewelPreviewFn[jewelType.name] - local lines = fn and fn() or nil - if type(lines) ~= "table" then - return nil - end - if jewelType.isThread 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 - local note - if jewelType.isThread then - note = "Multiple ring sizes available" - else - note = "Multiple variants available" - end - t_insert(genericLines, { height = 16, [1] = COL_META .. note }) - return genericLines - end local function isAnyFinderDropdownDropped() return (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) or (controls.jewelVariantSelect and controls.jewelVariantSelect.dropped) @@ -2170,165 +2345,41 @@ local function buildRadiusJewelPopupContext(self) saveFinderState() end - local previewListData = { } - local resultDetailListData = { } - local previewListY = contentTopY - local previewListHeight = 180 - local compactPreviewListHeight = 48 - local resultDetailBottomY = resultListBottomY - local resultDetailGap = 6 - local resultDetailLabelGap = 18 - local function getSelectedAllJewelPreviewLines() - local mode = controls.resultsList and controls.resultsList.mode - if mode ~= "computeSocketAll" then - return nil - end - local row = controls.resultsList.selValue - return row and row.itemTooltipLines or nil - end - local function isCompactPreview() - return selectedJewelType and selectedJewelType.isAllJewels and not getSelectedAllJewelPreviewLines() - end - local function getPreviewListHeight() - return isCompactPreview() and compactPreviewListHeight or previewListHeight - end - local function getResultDetailLabelY() - return previewListY + getPreviewListHeight() + resultDetailGap - end - local function getResultDetailListY() - return getResultDetailLabelY() + resultDetailLabelGap - end - local function updateResultDetails(row) - wipeTable(resultDetailListData) - if not row then - t_insert(resultDetailListData, { height = 16, [1] = COL_META .. "Select a result to view details." }) - return - end - t_insert(resultDetailListData, { height = 16, [1] = "^7Socket: " .. (row.socketLabel or "(n/a)") }) - if row.variantLabel and row.variantLabel ~= "" then - t_insert(resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel }) - end - local actionPlan = row.actionPlan - local action = actionPlan and actionPlan.kind or row.action - if actionPlan and not actionPlan.targetSocketAllocated then - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33This socket is unallocated and hidden from the Items panel." }) - t_insert(resultDetailListData, { height = 16, [1] = "^8Add to build keeps the jewel in the item list; placement uses the hidden socket." }) - end - local replacementItem = actionPlan and actionPlan.replacedTargetId - and self.build.itemsTab.items[actionPlan.replacedTargetId] - if not replacementItem and (row.replacedItemLabel or row.storedUnallocatedItemLabel) then - local occupancy = self:getSocketOccupancyInfo(row.socketId) - replacementItem = occupancy and occupancy.item - end - if actionPlan and actionPlan.sourceItemId then - local sourceText = actionPlan.sourceSocketId - and (actionPlan.sourceItemLabel .. " in " .. actionPlan.sourceSocketLabel) - or (actionPlan.sourceItemLabel .. " from Items") - t_insert(resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77" .. sourceText }) - elseif actionPlan then - t_insert(resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77New " .. (actionPlan.targetIdentity.uniqueName or "jewel") }) - end - if action == "equipped" then - t_insert(resultDetailListData, { height = 16, [1] = "^8Already equipped" }) - elseif action == "move" then - t_insert(resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" }) - if actionPlan and actionPlan.replacedTargetLabel then - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. actionPlan.replacedTargetLabel, item = replacementItem }) - end - elseif action == "replace" then - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" }) - local replacementLabel = actionPlan and actionPlan.replacedTargetLabel - or row.replacedItemLabel or row.storedUnallocatedItemLabel or "?" - t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. replacementLabel, item = replacementItem }) - else - t_insert(resultDetailListData, { height = 16, [1] = "^2Use free socket" }) - end - if row.detailText and row.detailText ~= "" then - t_insert(resultDetailListData, { height = 16, [1] = "^7" .. row.detailText }) - end - local nodeEntries = row.resultNodes or row.topNodes - if nodeEntries and #nodeEntries > 0 then - t_insert(resultDetailListData, { height = 6, [1] = "" }) - t_insert(resultDetailListData, { - height = 16, - [1] = row.resultNodes and s_format("^7Passives to allocate (%d):", #nodeEntries) - or s_format("^7Passives in range (%d):", #nodeEntries), - }) - for _, nodeEntry in ipairs(nodeEntries) do - t_insert(resultDetailListData, { - height = 16, - [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)), - nodeId = nodeEntry.nodeId, - }) - end - else - t_insert(resultDetailListData, { height = 6, [1] = "" }) - t_insert(resultDetailListData, { height = 16, [1] = row.resultNodes and (COL_META .. "No passives to allocate") or (COL_META .. "No passives in range") }) - end - t_insert(resultDetailListData, { height = 16, [1] = "^8Passive allocations are not applied automatically." }) + 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, + selectedAllJewelsView = selectedAllJewelsView, + } end - controls.previewList = new("TextListControl"):TextListControl(TL, { rightPanelX, previewListY, rightPanelWidth, previewListHeight }, - { { x = 0, align = "LEFT" }, { x = 210, align = "LEFT" } }, previewListData) - controls.previewList.height = getPreviewListHeight - controls.previewList.shown = function() - return not (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) + local function buildPreviewLinesForJewelType(jewelType, previewVariant) + return resultPresentation:buildPreviewLines(buildPreviewRequest(jewelType, previewVariant)) end - controls.resultDetailLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, 256, 0, 16 }, "^7Details:") - controls.resultDetailLabel.y = getResultDetailLabelY - controls.resultDetailList = new("RadiusJewelDetailListControl"):RadiusJewelDetailListControl(TL, { rightPanelX, 274, rightPanelWidth, 156 }, - { { x = 0, align = "LEFT" } }, resultDetailListData, self.build, socketViewer) - controls.resultDetailList.y = getResultDetailListY - controls.resultDetailList.height = function() - return resultDetailBottomY - getResultDetailListY() + local function buildGenericTypeTooltipLinesForJewelType(jewelType) + return resultPresentation:buildGenericTypeTooltipLines(buildPreviewRequest(jewelType)) end - updateResultDetails(nil) - - local function addPreviewLines(lines) - if type(lines) ~= "table" then - return false - end - for _, line in ipairs(lines) do - t_insert(previewListData, line) - end - return #lines > 0 + local function addPreviewLinesToTooltip(tooltip, lines) + resultPresentation:addPreviewLinesToTooltip(tooltip, lines) end - local function updatePreview(row) - wipeTable(previewListData) - if not selectedJewelType then - t_insert(previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) - return - end - if selectedJewelType.isAllJewels then - local mode = controls.resultsList and controls.resultsList.mode - if mode == "computeSocketAll" then - local previewRow = row or controls.resultsList.selValue - if previewRow and addPreviewLines(previewRow.itemTooltipLines) then - return - end - end - t_insert(previewListData, { height = 16, [1] = "^7Evaluate every jewel type." }) - if selectedAllJewelsView.id == "bestPerSocket" then - t_insert(previewListData, { height = 16, [1] = "^7Best jewel per socket." }) - else - t_insert(previewListData, { height = 16, [1] = "^7Sorted globally by %/Pt." }) - end - return - end - local lines = buildPreviewLinesForJewelType(selectedJewelType) - if type(lines) ~= "table" then - t_insert(previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) - return - end - addPreviewLines(lines) + resultPresentation:updatePreview(buildPreviewRequest(selectedJewelType), row) 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) - updateResultDetails(row) - updatePreview(row) + resultPresentation:selectResult(row, buildPreviewRequest(selectedJewelType)) end) controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") From a5f2effaaff00d22b586f9b1bead33fac0a9662c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 18 Aug 2026 22:08:50 +0200 Subject: [PATCH 43/52] Keep stale radius jewel results visible Explain criterion changes without hiding the previous result view. Keep stale actions disabled and restore matching cached results when criteria return. --- manifest.xml | 2 +- spec/System/TestRadiusJewelFinder_spec.lua | 30 ++++++++++++++++++---- src/Classes/RadiusJewelFinder.lua | 17 +++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/manifest.xml b/manifest.xml index 0c061ea660..d5881ad72f 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index d0362444ad..ee1a81c680 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -167,6 +167,14 @@ describe("RadiusJewelFinder #radius-jewel", function() 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 + it("dispatches every jewel strategy to its compute owner", function() build.radiusJewelFinderState = nil local finder = makeFinder() @@ -310,10 +318,12 @@ describe("RadiusJewelFinder #radius-jewel", function() end) - it("clears or restores results for every result-affecting criterion", function() + it("keeps stale results visible, blocks Apply, and restores matching results", function() local _, popup = openResultContextTestPopup() runPopupCompute(popup) local resultContextKey = popup.controls.resultsList.list[1].resultContextKey + local resultMode = popup.controls.resultsList.mode + local criteriaChangedMessage = "^xFFAA33Criteria changed. ^8Run Find or Compute again." local intuitiveLeapIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap") local threadOfHopeIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope") assert.is_string(resultContextKey) @@ -352,7 +362,15 @@ describe("RadiusJewelFinder #radius-jewel", function() } for _, criterion in ipairs(changes) do criterion.change() - assertResultsCleared(popup, criterion.name .. " should clear unmatched results") + assertStaleResultsRemainVisible(popup, resultContextKey, 1, resultMode, + criterion.name .. " should keep the previous results visible but stale") + assert.are.equal(criteriaChangedMessage, 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.applyButton:Click() + support.assertFinderStateUnchanged(beforeApply, assert) criterion.restore() assertCachedResultsAreApplicable(popup, resultContextKey, 1, criterion.name .. " should restore matching cached results") @@ -390,7 +408,7 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_true(#popup.controls.variantGroupSelect.list > 1) popup.controls.variantGroupSelect.selFunc(2) - assertResultsCleared(popup) + assertStaleResultsRemainVisible(popup, groupedContextKey, groupedResultCount, "computeSocket") popup.controls.variantGroupSelect.selFunc(1) assertCachedResultsAreApplicable(popup, groupedContextKey, groupedResultCount) @@ -400,7 +418,9 @@ describe("RadiusJewelFinder #radius-jewel", function() local allJewelsResultCount = #popup.controls.resultsList.list popup.controls.showLegacyCheck.changeFunc(true) - assertResultsCleared(popup) + assertStaleResultsRemainVisible(popup, allJewelsContextKey, allJewelsResultCount, "computeSocketAll") + assert.are.equal("^xFFAA33Criteria changed. ^8Run Compute again.", + popup.controls.statusLabel.label) popup.controls.showLegacyCheck.changeFunc(false) assertCachedResultsAreApplicable(popup, allJewelsContextKey, allJewelsResultCount) end) @@ -444,7 +464,7 @@ describe("RadiusJewelFinder #radius-jewel", function() local anyRingContextKey = findRow.resultContextKey popup.controls.threadVariantSelect.selFunc(2) - assertResultsCleared(popup) + assertStaleResultsRemainVisible(popup, anyRingContextKey, 1, "findThread") local explicitRingPreview = getPreviewText(popup) assert.matches(threadVariants[1].ringLabel, explicitRingPreview, 1, true) assert.is_nil(explicitRingPreview:find("Multiple ring sizes available", 1, true)) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 3771c3ecb9..72dd01a640 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -848,6 +848,18 @@ function RadiusJewelResultState:clear(isAllJewels) self.controls.resultsList:SetMode("message", { }, message) end +function RadiusJewelResultState:showCriteriaChanged(isAllJewels) + self.computeState.lastComputeAllRows = nil + self.computeState.lastComputeAllResultContextKey = nil + local message = isAllJewels + 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", { }, message) + end +end + function RadiusJewelResultState:rememberVisibleView(resultContextKey) local mode = self.controls.resultsList.mode local viewName = (mode == "find" or mode == "findThread") and "find" @@ -2153,6 +2165,9 @@ local function buildRadiusJewelPopupContext(self) local function clearResultsForContext() resultState:clear(selectedJewelType and selectedJewelType.isAllJewels) end + local function showCriteriaChangedForContext() + resultState:showCriteriaChanged(selectedJewelType and selectedJewelType.isAllJewels) + end local function saveVisibleResultView(resultContextKey) resultState:rememberVisibleView(resultContextKey) end @@ -2167,7 +2182,7 @@ local function buildRadiusJewelPopupContext(self) saveFinderState() local resultContextKey = getResultContextKey() if not restoreCachedResults(resultContextKey) then - clearResultsForContext() + showCriteriaChangedForContext() end end local function formatComputeStatus(itemLabel, statLabel, baseline, methodLabel) From 8053e95e9e827658c5f2c86ffbff062c2eb42059 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 19 Aug 2026 00:33:35 +0200 Subject: [PATCH 44/52] Clarify Find availability for jewel variants Keep Find visible but disabled for ordinary All variants selections while preserving the existing multi-variant behavior for Impossible Escape and Thread of Hope. Add coverage for messages, tooltips, grouped variants, and restored selections. Addresses PR 10057 D8. --- manifest.xml | 2 +- spec/System/TestRadiusJewelFinder_spec.lua | 86 ++++++++++++++++++++-- src/Classes/RadiusJewelFinder.lua | 52 ++++++++----- 3 files changed, 115 insertions(+), 25 deletions(-) diff --git a/manifest.xml b/manifest.xml index d5881ad72f..e642745577 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index ee1a81c680..b0f5db1b5d 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -324,6 +324,7 @@ describe("RadiusJewelFinder #radius-jewel", function() local resultContextKey = popup.controls.resultsList.list[1].resultContextKey local resultMode = popup.controls.resultsList.mode 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") assert.is_string(resultContextKey) @@ -333,38 +334,44 @@ describe("RadiusJewelFinder #radius-jewel", function() 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 criterion.change() assertStaleResultsRemainVisible(popup, resultContextKey, 1, resultMode, criterion.name .. " should keep the previous results visible but stale") - assert.are.equal(criteriaChangedMessage, popup.controls.statusLabel.label, + 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") @@ -377,6 +384,71 @@ describe("RadiusJewelFinder #radius-jewel", function() end 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() @@ -922,8 +994,10 @@ describe("RadiusJewelFinder #radius-jewel", function() "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_false(popup.controls.findButton:IsShown(), - "Find should stay hidden while all Intuitive Leap variants are selected") + 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 @@ -950,8 +1024,10 @@ describe("RadiusJewelFinder #radius-jewel", function() 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_false(popup.controls.findButton:IsShown(), - "Find should be hidden while all variants are selected") + 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 diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 72dd01a640..6ae6e2f997 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -838,20 +838,21 @@ function RadiusJewelResultState:save(request) return true end -function RadiusJewelResultState:clear(isAllJewels) +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", { }, message) end -function RadiusJewelResultState:showCriteriaChanged(isAllJewels) +function RadiusJewelResultState:showCriteriaChanged(isAllJewels, canFind) self.computeState.lastComputeAllRows = nil self.computeState.lastComputeAllResultContextKey = nil - local message = isAllJewels + 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 @@ -2085,6 +2086,15 @@ local function buildRadiusJewelPopupContext(self) local suppressFinderStateSave = false local runFind local cancelCompute + local function canFindCurrentSelection() + if not selectedJewelType or selectedJewelType.isAllJewels then + return false + end + if selectedJewelType.isThread or selectedJewelType.isImpossibleEscape then + return true + end + return not selectedJewelType.variants or selectedJewelVariant ~= nil + end local function formatElapsed(startTime) if not startTime then return "" end @@ -2163,10 +2173,10 @@ local function buildRadiusJewelPopupContext(self) }) end local function clearResultsForContext() - resultState:clear(selectedJewelType and selectedJewelType.isAllJewels) + resultState:clear(selectedJewelType and selectedJewelType.isAllJewels, canFindCurrentSelection()) end local function showCriteriaChangedForContext() - resultState:showCriteriaChanged(selectedJewelType and selectedJewelType.isAllJewels) + resultState:showCriteriaChanged(selectedJewelType and selectedJewelType.isAllJewels, canFindCurrentSelection()) end local function saveVisibleResultView(resultContextKey) resultState:rememberVisibleView(resultContextKey) @@ -2596,10 +2606,6 @@ local function buildRadiusJewelPopupContext(self) if variants then selectedJewelVariant = idx == 1 and nil or variants[idx - 1] updatePreview() - if controls.findButton then - controls.findButton.shown = not (selectedJewelType and selectedJewelType.variants - and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) - end end end) end) @@ -2701,9 +2707,6 @@ local function buildRadiusJewelPopupContext(self) else selectedJewelVariant = nil end - if controls.findButton and hasVariants and not selectedJewelVariant and not selectedJewelType.isImpossibleEscape then - controls.findButton.shown = false - end if hasComputeMethods then syncComputeMethodSelect(selectedJewelType.computeMethods) end @@ -2739,7 +2742,12 @@ local function buildRadiusJewelPopupContext(self) end if index == 1 then addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(selectedJewelType)) - tooltip:AddLine(16, "^8Compute compares every displayed variant.") + if selectedJewelType.isImpossibleEscape 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] @@ -3022,10 +3030,20 @@ local function buildRadiusJewelPopupContext(self) runFind(true) end) controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels) + controls.findButton.enabled = canFindCurrentSelection controls.findButton.tooltipFunc = function(tooltip) tooltip:Clear(true) - tooltip:AddLine(16, "^7Find sockets with matching passives for this jewel.") - tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.") + if selectedJewelType and selectedJewelType.isThread and not selectedThreadVariant then + tooltip:AddLine(16, "^7Find compares every ring and ranks compatible sockets.") + elseif selectedJewelType and selectedJewelType.isImpossibleEscape and not selectedJewelVariant then + tooltip:AddLine(16, "^7Find compares every displayed Keystone variant and ranks compatible sockets.") + 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( @@ -3126,10 +3144,6 @@ local function buildRadiusJewelPopupContext(self) end end - if controls.findButton and selectedJewelType and selectedJewelType.variants then - controls.findButton.shown = not (not selectedJewelVariant and not selectedJewelType.isImpossibleEscape) - end - suppressFinderStateSave = false saveFinderState() updatePreview() From 5a3c743bd7b6595a0d69e435479ab38b98169a29 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 19 Aug 2026 22:38:20 +0200 Subject: [PATCH 45/52] Enforce Max points in radius jewel Find Filter Find candidates by displayed Points while keeping Score independent. Preserve zero-cost occupied sockets and isolate cached results by Max points. Addresses PR 10057 D9. --- manifest.xml | 2 +- spec/System/TestRadiusJewelFinder_spec.lua | 168 ++++++++++++++++++++- src/Classes/RadiusJewelFinder.lua | 11 +- 3 files changed, 175 insertions(+), 6 deletions(-) diff --git a/manifest.xml b/manifest.xml index e642745577..e755a8392d 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,7 @@ - + diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index b0f5db1b5d..ebdf702a1d 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -86,16 +86,26 @@ describe("RadiusJewelFinder #radius-jewel", function() describe("popup integration", function() local previousJewelRadius local previousMaxJewelRadius + local syntheticAllocatedNodeIds + local syntheticRadiusRestores before_each(function() previousJewelRadius = data.jewelRadius previousMaxJewelRadius = data.maxJewelRadius + 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 end) @@ -153,6 +163,58 @@ describe("RadiusJewelFinder #radius-jewel", function() 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 countEntries(tbl) + local count = 0 + for _ in pairs(tbl) do + count = count + 1 + end + return count + end + local function assertCachedResultsAreApplicable(popup, resultContextKey, expectedCount, message) assert.are.equal(expectedCount or 1, #popup.controls.resultsList.list, message) assert.are.equal(resultContextKey, popup.controls.resultsList.list[1].resultContextKey, message) @@ -384,6 +446,104 @@ describe("RadiusJewelFinder #radius-jewel", function() end end) + it("filters standard Find by Points, keeps Score independent, and restores each Max points value", 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) + assertStaleResultsRemainVisible(popup, maxPointsOneContextKey, 2, "find") + popup.controls.findButton:Click() + assert.are.equal(1, #popup.controls.resultsList.list) + assert.are.equal(0, popup.controls.resultsList.list[1].points) + assert.are.equal(2, countEntries(build.radiusJewelFinderState.findCache)) + + popup.controls.maxPointsEdit:SetText("1", true) + assertCachedResultsAreApplicable(popup, maxPointsOneContextKey, 2) + assert.are.equal(2, countEntries(build.radiusJewelFinderState.findCache)) + end) + + it("applies Max points to Thread Find and caches a zero-result value", 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) + assert.are.equal(1, countEntries(build.radiusJewelFinderState.findCache)) + + 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() @@ -877,8 +1037,12 @@ describe("RadiusJewelFinder #radius-jewel", function() 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("total passive points", 1, true) ~= nil, - "expected Max points tooltip to explain total point limit") + 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") diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 6ae6e2f997..04dd291044 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -1634,6 +1634,7 @@ local function runRadiusJewelFind(self, context, makePreferred) 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 @@ -1675,7 +1676,9 @@ local function runRadiusJewelFind(self, context, makePreferred) for _, socket in ipairs(jewelSockets) do local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, selectedOccupiedMode) local socketNode = treeData.nodes[socket.id] - if socketAllowed and socketNode and (socketNode.nodesInRadius or strategy.allowsSocketWithoutRadius) then + 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 @@ -2513,8 +2516,9 @@ local function buildRadiusJewelPopupContext(self) end) local function addMaxPointsTooltip(tooltip) tooltip:Clear(true) - tooltip:AddLine(16, "^7Maximum total passive points for a result.") - tooltip:AddLine(16, "^8Includes pathing to the socket and passives to allocate.") + 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 @@ -3015,6 +3019,7 @@ local function buildRadiusJewelPopupContext(self) jewelSockets = jewelSockets, selectedJewelType = selectedJewelType, selectedJewelVariant = selectedJewelVariant, + selectedMaxPoints = selectedMaxPoints, selectedOccupiedMode = selectedOccupiedMode, resultContextKey = resultContextKey, getSelectedVariants = getSelectedVariants, From 301727ba98b46809541f6001c8fbf06d6a37cb22 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 16:00:17 +0200 Subject: [PATCH 46/52] Streamline radius jewel result details Replace the preview split with a full-height, fact-first Details pane while preserving Results geometry and action behavior. Cache stable hover tooltips and avoid repeating variant and passive-count summaries. Implements decision D10 for PR 10057. --- spec/System/TestRadiusJewelActions_spec.lua | 23 +- spec/System/TestRadiusJewelFinder_spec.lua | 358 ++++++++++++++++++- src/Classes/RadiusJewelDetailListControl.lua | 17 +- src/Classes/RadiusJewelFinder.lua | 223 ++++-------- 4 files changed, 446 insertions(+), 175 deletions(-) diff --git a/spec/System/TestRadiusJewelActions_spec.lua b/spec/System/TestRadiusJewelActions_spec.lua index 98731e71b3..3931417174 100644 --- a/spec/System/TestRadiusJewelActions_spec.lua +++ b/spec/System/TestRadiusJewelActions_spec.lua @@ -338,12 +338,18 @@ describe("RadiusJewelFinder actions #radius-jewel", function() 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("This socket is unallocated", 1, true) ~= nil) - assert.is_true(details:find("hidden from the Items panel", 1, true) ~= nil) + 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() @@ -464,6 +470,9 @@ describe("RadiusJewelFinder actions #radius-jewel", function() 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() @@ -506,14 +515,15 @@ describe("RadiusJewelFinder actions #radius-jewel", function() 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("Source:", 1, true) ~= nil) + 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("Source:", 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_true(details:find("not applied automatically", 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) @@ -634,6 +644,9 @@ describe("RadiusJewelFinder actions #radius-jewel", function() 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()) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index ebdf702a1d..4bbdb72fec 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -86,12 +86,14 @@ describe("RadiusJewelFinder #radius-jewel", function() 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) @@ -108,6 +110,7 @@ describe("RadiusJewelFinder #radius-jewel", function() end data.jewelRadius = previousJewelRadius data.maxJewelRadius = previousMaxJewelRadius + GetCursorPos = previousGetCursorPos end) local function findControlIndex(list, needle) @@ -126,10 +129,12 @@ describe("RadiusJewelFinder #radius-jewel", function() end end - local function getPreviewText(popup) + local function getDropdownTooltipText(control, index) + local tooltip = new("Tooltip"):Tooltip() + control.tooltipFunc(tooltip, "DROP", index, control.list[index]) local lines = { } - for _, line in ipairs(popup.controls.previewList.list) do - table.insert(lines, line[1] or "") + for _, line in ipairs(tooltip.lines) do + table.insert(lines, line.text or "") end return table.concat(lines, "\n") end @@ -237,6 +242,334 @@ describe("RadiusJewelFinder #radius-jewel", function() 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("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() @@ -685,10 +1018,10 @@ describe("RadiusJewelFinder #radius-jewel", function() for index, variant in ipairs(threadVariants) do assert.are.equal(variant.ringLabel, popup.controls.threadVariantSelect.list[index + 1]) end - local anyRingPreview = getPreviewText(popup) - assert.matches("Multiple ring sizes available", anyRingPreview, 1, true) + 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(anyRingPreview:find(variant.ringLabel, 1, true)) + assert.is_nil(anyRingTooltip:find(variant.ringLabel, 1, true)) end popup.controls.findButton:Click() local findRow = popup.controls.resultsList.list[1] @@ -697,9 +1030,9 @@ describe("RadiusJewelFinder #radius-jewel", function() popup.controls.threadVariantSelect.selFunc(2) assertStaleResultsRemainVisible(popup, anyRingContextKey, 1, "findThread") - local explicitRingPreview = getPreviewText(popup) - assert.matches(threadVariants[1].ringLabel, explicitRingPreview, 1, true) - assert.is_nil(explicitRingPreview:find("Multiple ring sizes available", 1, true)) + 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] @@ -988,7 +1321,6 @@ describe("RadiusJewelFinder #radius-jewel", function() for _, controlName in ipairs({ "computeButton", "impactStatSelect", - "previewList", "resultDetailList", "findButton", "addToBuildButton", @@ -1136,8 +1468,8 @@ describe("RadiusJewelFinder #radius-jewel", function() action = "equip", }, }, "(no compatible sockets)") - assert.are.equal("^7Selected Jewel", popup.controls.previewList.list[1][1]) - assert.are.equal(180, popup.controls.previewList.height()) + 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") @@ -1145,8 +1477,6 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_true(allJewelsSocketHover.showViewer, "All jewels Compute socket column should show socket preview") popup.controls.resultsList:SetMode("message", { }, "Click Compute") - assert.are.equal("^7Evaluate every jewel type.", popup.controls.previewList.list[1][1]) - assert.are.equal(48, popup.controls.previewList.height()) -- Intuitive Leap: tooltip, compute method, occupied mode local intuitiveIdx = findIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap") diff --git a/src/Classes/RadiusJewelDetailListControl.lua b/src/Classes/RadiusJewelDetailListControl.lua index bb70b4597c..bfaf50bdab 100644 --- a/src/Classes/RadiusJewelDetailListControl.lua +++ b/src/Classes/RadiusJewelDetailListControl.lua @@ -50,8 +50,9 @@ function RadiusJewelDetailListClass:Draw(viewPort) local cursorX, cursorY = GetCursorPos() if hoverLine.item then SetDrawLayer(nil, 100) - self.itemTooltip:Clear(true) - self.build.itemsTab:AddItemTooltip(self.itemTooltip, hoverLine.item) + 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) @@ -90,11 +91,13 @@ function RadiusJewelDetailListClass:Draw(viewPort) SetViewport() SetDrawLayer(nil, 100) - self.nodeTooltip:Clear(true) - local prevShowStatDifferences = self.socketViewer.showStatDifferences - self.socketViewer.showStatDifferences = true - self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build) - self.socketViewer.showStatDifferences = prevShowStatDifferences + 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) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 04dd291044..f7e4949000 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -846,7 +846,7 @@ function RadiusJewelResultState:clear(isAllJewels, canFind) 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", { }, message) + self.controls.resultsList:SetMode("message", { }, "") end function RadiusJewelResultState:showCriteriaChanged(isAllJewels, canFind) @@ -857,7 +857,7 @@ function RadiusJewelResultState:showCriteriaChanged(isAllJewels, canFind) 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", { }, message) + self.controls.resultsList:SetMode("message", { }, "") end end @@ -1023,11 +1023,14 @@ function RadiusJewelResultActions:applyTooltip(tooltip) 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 plan.sourceItemId then - local source = plan.sourceSocketId and plan.sourceSocketLabel or "Items" - tooltip:AddLine(16, "^7Source: ^x33FF77" .. plan.sourceItemLabel .. " ^7in " .. source) + 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, "^7Source: ^x33FF77New jewel") + tooltip:AddLine(16, "^7Current location: " .. plan.sourceSocketLabel) end if plan.replacedTargetId then tooltip:AddLine(16, "^xFFAA33Replaces: ^7" .. plan.replacedTargetLabel .. " in " .. plan.targetSocketLabel) @@ -1077,9 +1080,7 @@ function RadiusJewelResultPresentation:new(finder, controls, socketViewer, layou finder = finder, controls = controls, layout = layout, - previewListData = { }, resultDetailListData = { }, - compactPreview = false, }, self) presentation:createControls(socketViewer) presentation:updateResultDetails(nil) @@ -1165,158 +1166,95 @@ function RadiusJewelResultPresentation:buildGenericTypeTooltipLines(request) return genericLines end -function RadiusJewelResultPresentation:getPreviewListHeight() - return self.compactPreview and 48 or 180 -end - -function RadiusJewelResultPresentation:getResultDetailLabelY() - return self.layout.y + self:getPreviewListHeight() + 6 -end - -function RadiusJewelResultPresentation:getResultDetailListY() - return self:getResultDetailLabelY() + 18 +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 - t_insert(self.resultDetailListData, { height = 16, [1] = "^7Socket: " .. (row.socketLabel or "(n/a)") }) + 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 - local actionPlan = row.actionPlan - local action = actionPlan and actionPlan.kind or row.action - if actionPlan and not actionPlan.targetSocketAllocated then - t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33This socket is unallocated and hidden from the Items panel." }) - t_insert(self.resultDetailListData, { height = 16, [1] = "^8Add to build keeps the jewel in the item list; placement uses the hidden socket." }) + 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 - if actionPlan and actionPlan.sourceItemId then - local sourceText = actionPlan.sourceSocketId - and (actionPlan.sourceItemLabel .. " in " .. actionPlan.sourceSocketLabel) - or (actionPlan.sourceItemLabel .. " from Items") - t_insert(self.resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77" .. sourceText }) - elseif actionPlan then - t_insert(self.resultDetailListData, { height = 16, [1] = "^7Source: ^x33FF77New " .. (actionPlan.targetIdentity.uniqueName or "jewel") }) - end - if action == "equipped" then - t_insert(self.resultDetailListData, { height = 16, [1] = "^8Already equipped" }) - elseif action == "move" then - t_insert(self.resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" }) - if actionPlan and actionPlan.replacedTargetLabel then - t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. actionPlan.replacedTargetLabel, item = replacementItem }) - end - elseif action == "replace" then - t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" }) - local replacementLabel = actionPlan and actionPlan.replacedTargetLabel - or row.replacedItemLabel or row.storedUnallocatedItemLabel or "?" + 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 }) - else - t_insert(self.resultDetailListData, { height = 16, [1] = "^2Use free socket" }) end - if row.detailText and row.detailText ~= "" then + 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 - local nodeEntries = row.resultNodes or row.topNodes - if nodeEntries and #nodeEntries > 0 then + if nodeEntries then t_insert(self.resultDetailListData, { height = 6, [1] = "" }) - t_insert(self.resultDetailListData, { - height = 16, - [1] = row.resultNodes and s_format("^7Passives to allocate (%d):", #nodeEntries) - or s_format("^7Passives in range (%d):", #nodeEntries), - }) - for _, nodeEntry in ipairs(nodeEntries) do + if #nodeEntries > 0 then t_insert(self.resultDetailListData, { height = 16, - [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)), - nodeId = nodeEntry.nodeId, + [1] = isRecommendation and s_format("^7Recommended passives (%d):", #nodeEntries) + or s_format("^7Notables and keystones in range (%d):", #nodeEntries), }) - end - else - t_insert(self.resultDetailListData, { height = 6, [1] = "" }) - t_insert(self.resultDetailListData, { height = 16, [1] = row.resultNodes and (COL_META .. "No passives to allocate") or (COL_META .. "No passives in range") }) - end - t_insert(self.resultDetailListData, { height = 16, [1] = "^8Passive allocations are not applied automatically." }) -end - -function RadiusJewelResultPresentation:addPreviewLines(lines) - if type(lines) ~= "table" then - return false - end - for _, line in ipairs(lines) do - t_insert(self.previewListData, line) - end - return #lines > 0 -end - -function RadiusJewelResultPresentation:updatePreview(request, row) - wipeTable(self.previewListData) - self.compactPreview = false - local selectedJewelType = request.selectedJewelType - if not selectedJewelType then - t_insert(self.previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) - return - end - if selectedJewelType.isAllJewels then - local mode = self.controls.resultsList and self.controls.resultsList.mode - local selectedPreviewLines - if mode == "computeSocketAll" then - local previewRow = row or self.controls.resultsList.selValue - selectedPreviewLines = previewRow and previewRow.itemTooltipLines - if previewRow and self:addPreviewLines(previewRow.itemTooltipLines) then - return + for _, nodeEntry in ipairs(nodeEntries) do + t_insert(self.resultDetailListData, { + height = 16, + [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)), + nodeId = nodeEntry.nodeId, + }) end - end - self.compactPreview = not selectedPreviewLines - t_insert(self.previewListData, { height = 16, [1] = "^7Evaluate every jewel type." }) - if request.selectedAllJewelsView.id == "bestPerSocket" then - t_insert(self.previewListData, { height = 16, [1] = "^7Best jewel per socket." }) else - t_insert(self.previewListData, { height = 16, [1] = "^7Sorted globally by %/Pt." }) + t_insert(self.resultDetailListData, { height = 16, [1] = isRecommendation + and (COL_META .. "No recommended passives") + or (COL_META .. "No notables or keystones in range") }) end - return - end - local lines = self:buildPreviewLines(request) - if type(lines) ~= "table" then - t_insert(self.previewListData, { height = 16, [1] = COL_META .. "(no preview)" }) - return end - self:addPreviewLines(lines) -end - -function RadiusJewelResultPresentation:selectResult(row, previewRequest) - self:updateResultDetails(row) - self:updatePreview(previewRequest, row) + self:resetResultDetailScroll() end function RadiusJewelResultPresentation:createControls(socketViewer) local controls = self.controls local layout = self.layout - controls.previewList = new("TextListControl"):TextListControl(layout.anchor, - { layout.x, layout.y, layout.width, 180 }, - { { x = 0, align = "LEFT" }, { x = 210, align = "LEFT" } }, self.previewListData) - controls.previewList.height = function() return self:getPreviewListHeight() end - controls.previewList.shown = function() - return not (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped) - end controls.resultDetailLabel = new("LabelControl"):LabelControl(layout.anchor, - { layout.x, 256, 0, 16 }, "^7Details:") - controls.resultDetailLabel.y = function() return self:getResultDetailLabelY() end + { layout.x, layout.y, 0, 16 }, "^7Details:") controls.resultDetailList = new("RadiusJewelDetailListControl"):RadiusJewelDetailListControl(layout.anchor, - { layout.x, 274, layout.width, 156 }, + { layout.x, layout.y + 18, layout.width, layout.bottomY - layout.y - 18 }, { { x = 0, align = "LEFT" } }, self.resultDetailListData, self.finder.build, socketViewer) - controls.resultDetailList.y = function() return self:getResultDetailListY() end - controls.resultDetailList.height = function() - return layout.bottomY - self:getResultDetailListY() - end end local function buildRadiusJewelPopupSetup(self) @@ -1515,7 +1453,6 @@ local function findSplitPersonalitySocket(self, request) return { socket = request.socket, score = score, - topNodes = { }, 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, @@ -1699,7 +1636,8 @@ local function runRadiusJewelFind(self, context, makePreferred) end local rows = { } for _, r in ipairs(results) do - local topLabels = buildNodeLabelList(r.topNodes) + local topNodes = r.topNodes or { } + local topLabels = buildNodeLabelList(topNodes) local topStr = t_concat(topLabels, ", ") if #topStr > 50 then topStr = topStr:sub(1, 47) .. "..." @@ -1714,9 +1652,9 @@ local function runRadiusJewelFind(self, context, makePreferred) local sortValue = points > 0 and scorePerPoint or r.score local detailText = r.detailText if not detailText or detailText == "" then - detailText = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel + 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", #r.topNodes, #r.topNodes == 1 and "" or "es") + detailText = detailText .. s_format(" | %d match%s", #topNodes, #topNodes == 1 and "" or "es") end local detailNodeId = strategy.getDetailNodeId and strategy.getDetailNodeId(findRequest, r.variant) or nil local targetIdentity = r.variant and r.variant.variantIdentity @@ -1743,7 +1681,7 @@ local function runRadiusJewelFind(self, context, makePreferred) or r.variant.dropdownLabel or r.variant.name) or "", detailText = detailText, detailNodeId = detailNodeId, - topNodes = copyTableSafe(r.topNodes, false, true), + topNodes = r.topNodes and copyTableSafe(r.topNodes, false, true), replacedItemLabel = r.replacedItemLabel, storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, action = actionPlan and actionPlan.kind or nil, @@ -1765,10 +1703,10 @@ local function runRadiusJewelFind(self, context, makePreferred) end end) if not ok then - controls.statusLabel.label = "^1Error: " .. tostring(err) + controls.statusLabel.label = "^1Search failed" controls.resultsList:SetMode("message", { { text = "^1" .. tostring(err) }, - }, "^1Error") + }, "") end end @@ -2014,10 +1952,10 @@ local function runRadiusJewelCompute(self, context) local res, errMsg = coroutine.resume(computeState.computeContext.co) if not res then cancelCompute() - controls.statusLabel.label = "^1Error: " .. tostring(errMsg) + controls.statusLabel.label = "^1Compute failed" controls.resultsList:SetMode("message", { { text = "^1" .. tostring(errMsg) }, - }, "^1Error") + }, "") return end if coroutine.status(computeState.computeContext.co) == "dead" then @@ -2209,9 +2147,7 @@ local function buildRadiusJewelPopupContext(self) end local function setComputeProgress(message) controls.statusLabel.label = message - controls.resultsList:SetMode("message", { - { text = message }, - }, message) + controls.resultsList:SetMode("message", { }, "") end cancelCompute = function(statusMessage) if not computeState.computeContext then @@ -2387,7 +2323,6 @@ local function buildRadiusJewelPopupContext(self) selectedJewelType = selectedJewelType, selectedJewelVariant = selectedJewelVariant, selectedThreadVariant = selectedThreadVariant, - selectedAllJewelsView = selectedAllJewelsView, } end local function buildPreviewLinesForJewelType(jewelType, previewVariant) @@ -2399,17 +2334,13 @@ local function buildRadiusJewelPopupContext(self) local function addPreviewLinesToTooltip(tooltip, lines) resultPresentation:addPreviewLinesToTooltip(tooltip, lines) end - local function updatePreview(row) - resultPresentation:updatePreview(buildPreviewRequest(selectedJewelType), row) - 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:selectResult(row, buildPreviewRequest(selectedJewelType)) + resultPresentation:updateResultDetails(row) end) - controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search") + controls.resultsList:SetMode("message", { }, "") local function rebuildJewelTypeDropdown() jewelTypes = buildJewelTypes() @@ -2583,7 +2514,6 @@ local function buildRadiusJewelPopupContext(self) controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx) onCriteriaChanged(function() selectedThreadVariant = idx == 1 and nil or threadVariants[idx - 1] - updatePreview() end) end) controls.threadVariantLabel.shown = false @@ -2596,7 +2526,6 @@ local function buildRadiusJewelPopupContext(self) controls.jewelVariantSelect.selIndex = 1 selectedJewelVariant = nil syncDisplayedVariants() - updatePreview() end) end) controls.variantGroupLabel.shown = false @@ -2609,7 +2538,6 @@ local function buildRadiusJewelPopupContext(self) local variants = getDisplayedVariants() if variants then selectedJewelVariant = idx == 1 and nil or variants[idx - 1] - updatePreview() end end) end) @@ -2722,7 +2650,6 @@ local function buildRadiusJewelPopupContext(self) selectedJewelType = activeJewelTypes[idx] controls.jewelVariantSelect.selIndex = 1 syncSelectedJewelTypeControls() - updatePreview() end) end) controls.jewelTypeSelect.tooltipFunc = function(tooltip, mode, index) @@ -2915,6 +2842,7 @@ local function buildRadiusJewelPopupContext(self) 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, @@ -2998,14 +2926,13 @@ local function buildRadiusJewelPopupContext(self) 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", { }, 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() - updatePreview() end) end) @@ -3058,7 +2985,6 @@ local function buildRadiusJewelPopupContext(self) local function restoreFinderState() if not finderState.jewelTypeName then - updatePreview() clearResultsForContext() return end @@ -3151,7 +3077,6 @@ local function buildRadiusJewelPopupContext(self) suppressFinderStateSave = false saveFinderState() - updatePreview() if not restoreCachedResults() then clearResultsForContext() end From 9024d1bb54070a893a9ee24ac7ea8d9c029b8e5e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 00:33:47 +0200 Subject: [PATCH 47/52] Simplify radius jewel result data and state Replace parallel jewel preview and result-column branches with explicit schemas. Remove optional cross-interaction result caches while preserving visible stale rows, refresh warnings, and Apply correctness guards. --- manifest.xml | 8 +- spec/System/TestRadiusJewelData_spec.lua | 7 + spec/System/TestRadiusJewelFinder_spec.lua | 179 +++++---- src/Classes/RadiusJewelData.lua | 341 ++++++------------ src/Classes/RadiusJewelFinder.lua | 164 +-------- src/Classes/RadiusJewelResultsListControl.lua | 196 ++++------ 6 files changed, 303 insertions(+), 592 deletions(-) diff --git a/manifest.xml b/manifest.xml index e755a8392d..75fafe64d4 100644 --- a/manifest.xml +++ b/manifest.xml @@ -172,10 +172,10 @@ - - - - + + + + diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua index 648da0bec3..917f11d882 100644 --- a/spec/System/TestRadiusJewelData_spec.lua +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -54,6 +54,13 @@ describe("RadiusJewelData #radius-jewel", function() -- ── buildJewelTypes ────────────────────────────────────────────────────── describe("buildJewelTypes", function() + it("registers previews only for known jewel types", function() + assert.is_nil(RadiusJewelData.jewelPreviewFn["Unknown Radius Jewel"]) + for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do + assert.is_function(RadiusJewelData.jewelPreviewFn[jewelType.name], + "missing preview function for " .. jewelType.name) + end + end) it("assigns one evaluation strategy to every jewel type", function() local strategy = RadiusJewelData.JEWEL_STRATEGY diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 4bbdb72fec..4fb5217052 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -212,21 +212,6 @@ describe("RadiusJewelFinder #radius-jewel", function() end end - local function countEntries(tbl) - local count = 0 - for _ in pairs(tbl) do - count = count + 1 - end - return count - end - - local function assertCachedResultsAreApplicable(popup, resultContextKey, expectedCount, message) - assert.are.equal(expectedCount or 1, #popup.controls.resultsList.list, message) - assert.are.equal(resultContextKey, popup.controls.resultsList.list[1].resultContextKey, message) - popup.controls.resultsList.selIndex = 1 - assert.is_true(popup.controls.applyButton.enabled(), message) - end - local function assertResultsCleared(popup, message) assert.are.equal("message", popup.controls.resultsList.mode, message) assert.are.equal(0, #popup.controls.resultsList.list, message) @@ -304,6 +289,74 @@ describe("RadiusJewelFinder #radius-jewel", function() 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() @@ -713,16 +766,12 @@ describe("RadiusJewelFinder #radius-jewel", function() end) - it("keeps stale results visible, blocks Apply, and restores matching results", function() + it("keeps stale results visible and blocks Apply when criteria change", function() local _, popup = openResultContextTestPopup() - runPopupCompute(popup) - local resultContextKey = popup.controls.resultsList.list[1].resultContextKey - local resultMode = popup.controls.resultsList.mode 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") - assert.is_string(resultContextKey) local changes = { { @@ -763,23 +812,25 @@ describe("RadiusJewelFinder #radius-jewel", function() }, } 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, resultContextKey, 1, resultMode, + 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.applyButton:Click() + popup.controls.resultsList.OnSelClick(popup.controls.resultsList, 1, staleRow, true) support.assertFinderStateUnchanged(beforeApply, assert) criterion.restore() - assertCachedResultsAreApplicable(popup, resultContextKey, 1, - criterion.name .. " should restore matching cached results") end end) - it("filters standard Find by Points, keeps Score independent, and restores each Max points value", function() + 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) @@ -813,18 +864,17 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.matches("2 results", popup.controls.statusLabel.label, 1, true) popup.controls.maxPointsEdit:SetText("0", true) - assertStaleResultsRemainVisible(popup, maxPointsOneContextKey, 2, "find") popup.controls.findButton:Click() assert.are.equal(1, #popup.controls.resultsList.list) assert.are.equal(0, popup.controls.resultsList.list[1].points) - assert.are.equal(2, countEntries(build.radiusJewelFinderState.findCache)) popup.controls.maxPointsEdit:SetText("1", true) - assertCachedResultsAreApplicable(popup, maxPointsOneContextKey, 2) - assert.are.equal(2, countEntries(build.radiusJewelFinderState.findCache)) + 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 and caches a zero-result value", function() + it("applies Max points to Thread Find including zero-result searches", function() build.radiusJewelFinderState = nil local socketId = 33631 local radiusIndices = { } @@ -843,7 +893,6 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.are.equal("findThread", popup.controls.resultsList.mode) assert.are.equal(0, #popup.controls.resultsList.list) - assert.are.equal(1, countEntries(build.radiusJewelFinderState.findCache)) popup.controls.maxPointsEdit:SetText("2", true) popup.controls.findButton:Click() @@ -973,21 +1022,39 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_true(#popup.controls.variantGroupSelect.list > 1) popup.controls.variantGroupSelect.selFunc(2) - assertStaleResultsRemainVisible(popup, groupedContextKey, groupedResultCount, "computeSocket") + runPopupCompute(popup) + assert.are_not.equal(groupedContextKey, popup.controls.resultsList.list[1].resultContextKey) popup.controls.variantGroupSelect.selFunc(1) - assertCachedResultsAreApplicable(popup, groupedContextKey, groupedResultCount) + 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) - assertStaleResultsRemainVisible(popup, allJewelsContextKey, allJewelsResultCount, "computeSocketAll") 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) - assertCachedResultsAreApplicable(popup, allJewelsContextKey, allJewelsResultCount) + 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() @@ -1029,7 +1096,6 @@ describe("RadiusJewelFinder #radius-jewel", function() local anyRingContextKey = findRow.resultContextKey popup.controls.threadVariantSelect.selFunc(2) - assertStaleResultsRemainVisible(popup, anyRingContextKey, 1, "findThread") 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)) @@ -1050,25 +1116,19 @@ describe("RadiusJewelFinder #radius-jewel", function() popup.controls.threadVariantSelect.selFunc(1) - assert.are.equal("findThread", popup.controls.resultsList.mode) - assert.are.equal(anyRingContextKey, popup.controls.resultsList.list[1].resultContextKey) assert.is_nil(build.radiusJewelFinderState.threadVariantName) end) - it("restores an explicit Thread ring and its cached result view", function() + 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() - local resultContextKey = popup.controls.resultsList.list[1].resultContextKey - popup.controls.closeButton:Click() local reopenedPopup = finder:Open() assert.are.equal(2, reopenedPopup.controls.threadVariantSelect.selIndex) - assert.are.equal("findThread", reopenedPopup.controls.resultsList.mode) - assert.are.equal(resultContextKey, reopenedPopup.controls.resultsList.list[1].resultContextKey) assert.are.equal(RadiusJewelData.getThreadOfHopeVariants()[1].name, build.radiusJewelFinderState.threadVariantName) end) @@ -1085,16 +1145,13 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_nil(main.onFrameFuncs["RadiusJewelFinderCompute"]) assert.is_false(computeCompleted()) assertResultsCleared(popup) - assert.is_nil(next(build.radiusJewelFinderState.computeCache)) - assert.is_nil(next(build.radiusJewelFinderState.resultViewByKey)) end) - it("cancels a suspended Compute before stale revision results can be saved", function() + 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"]) - assert.is_not_nil(next(build.radiusJewelFinderState.disconnectedPassivePlanCache)) build.outputRevision = build.outputRevision + 1 runCallback("OnFrame") @@ -1102,36 +1159,14 @@ describe("RadiusJewelFinder #radius-jewel", function() assert.is_nil(main.onFrameFuncs["RadiusJewelFinderCompute"]) assert.is_false(computeCompleted()) assertResultsCleared(popup) - assert.is_nil(next(build.radiusJewelFinderState.findCache)) - assert.is_nil(next(build.radiusJewelFinderState.computeCache)) - assert.is_nil(next(build.radiusJewelFinderState.resultViewByKey)) - assert.is_nil(next(build.radiusJewelFinderState.disconnectedPassivePlanCache)) - end) - - it("restores matching results after closing and reopening without a build mutation", function() - local finder, popup = openResultContextTestPopup() - runPopupCompute(popup) - local resultContextKey = popup.controls.resultsList.list[1].resultContextKey - assertCachedResultsAreApplicable(popup, resultContextKey) - - popup.controls.closeButton:Click() - local reopenedPopup = finder:Open() - - assertCachedResultsAreApplicable(reopenedPopup, resultContextKey) end) - it("invalidates every cache and blocks stale Apply after a build revision", function() + 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 - local finderState = build.radiusJewelFinderState - finderState.findCache["old-find"] = { } - assert.is_not_nil(next(finderState.findCache)) - assert.is_not_nil(next(finderState.computeCache)) - assert.is_not_nil(next(finderState.resultViewByKey)) - assert.is_not_nil(next(finderState.disconnectedPassivePlanCache)) popup.controls.closeButton:Click() build.outputRevision = build.outputRevision + 1 @@ -1142,10 +1177,6 @@ describe("RadiusJewelFinder #radius-jewel", function() local reopenedPopup = finder:Open() assertResultsCleared(reopenedPopup) - assert.is_nil(next(finderState.findCache)) - assert.is_nil(next(finderState.computeCache)) - assert.is_nil(next(finderState.resultViewByKey)) - assert.is_nil(next(finderState.disconnectedPassivePlanCache)) end) it("isolates grouped limit identities for All variants and All jewels Compute", function() diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 73558bd6a6..531387e664 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -165,10 +165,6 @@ local function assignVariantIdentity(candidate, family, variantGroup) return candidate end -local function getUniqueRadiusIndex(name, baseName) - return getRadiusIndexFromRawText(mustGetCurrentUniqueRawText(name, baseName)) -end - local function makeUniqueVariant(name, uniqueName, baseName) local rawText = mustGetCurrentUniqueRawText(uniqueName or name, baseName) return { @@ -681,23 +677,12 @@ local function previewUnique(uniqueName, displayName, baseName) return previewFromRawText(mustGetCurrentUniqueRawText(uniqueName, baseName), displayName) end -local function previewVariant(variant, displayName) - if variant and variant.rawText then - return previewFromRawText(variant.rawText, displayName or variant.name, variant.previewMeta) - end - return nil -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 previewVariantOrGroup(groupName, variant) - return previewVariant(variant) or previewFinderGroup(groupName) -end - local function previewThreadOfHope(ringName) if not ringName then return previewFinderGroup("Thread of Hope", "Multiple ring sizes available") @@ -719,164 +704,115 @@ local function previewThreadOfHope(ringName) return previewFromRawText(rawText, displayName) end -local jewelPreviewFn = { - ["The Light of Meaning"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText, "The Light of Meaning (" .. variant.name .. ")") - end - return previewFinderGroup("The Light of Meaning") - end, - - ["Might of the Meek"] = function(variant) - return previewVariant(variant) or previewUnique("Might of the Meek") - end, - - ["Unnatural Instinct"] = function(variant) - return previewVariant(variant) or previewUnique("Unnatural Instinct") - end, +local JEWEL_PREVIEW_SCHEMA = { + ["The Light of Meaning"] = { group = true, prefixVariantName = true }, + ["Might of the Meek"] = { }, + ["Unnatural Instinct"] = { }, + ["Inspired Learning"] = { }, + ["Anatomical Knowledge"] = { }, + ["Tempered & Transcendent"] = { group = true }, + ["Lioneye's Fall"] = { }, + ["Intuitive Leap"] = { }, + ["Impossible Escape"] = { group = true, prefixVariantName = true }, + ["Split Personality"] = { group = true, prefixVariantName = true }, + ["Stat Conversion"] = { group = true }, + ["Attribute Conversion"] = { group = true }, + ["Combat Focus"] = { group = true }, + ["Dreams & Nightmares"] = { group = true }, + ["Thread of Hope"] = { thread = true }, +} - ["Inspired Learning"] = function(variant) - return previewVariant(variant) or previewUnique("Inspired Learning") - end, +local function buildJewelPreview(name, schema, variant) + if schema.thread then + return previewThreadOfHope(variant) + elseif variant and variant.rawText then + local displayName = schema.prefixVariantName and (name .. " (" .. variant.name .. ")") or variant.name + return previewFromRawText(variant.rawText, displayName, variant.previewMeta) + elseif schema.group then + return previewFinderGroup(name) + end + return previewUnique(name) +end - ["Anatomical Knowledge"] = function() - return previewUnique("Anatomical Knowledge") - end, +local function makeJewelPreviewFn(name, schema) + return function(variant) + return buildJewelPreview(name, schema, variant) + end +end - ["Lioneye's Fall"] = function(variant) - return previewVariant(variant) or previewUnique("Lioneye's Fall") - end, +local jewelPreviewFn = { } +for name, schema in pairs(JEWEL_PREVIEW_SCHEMA) do + jewelPreviewFn[name] = makeJewelPreviewFn(name, schema) +end - ["Intuitive Leap"] = function(variant) - return previewVariant(variant) or previewUnique("Intuitive Leap") - end, +M.jewelPreviewFn = jewelPreviewFn - ["Tempered & Transcendent"] = function(variant) - return previewVariantOrGroup("Tempered & Transcendent", variant) - end, +-- ───────────────────────────────────────────────────────────────────────────── +-- Jewel type definitions +-- ───────────────────────────────────────────────────────────────────────────── - ["Split Personality"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText, "Split Personality (" .. variant.name .. ")") +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 previewFinderGroup("Split Personality") - end, + return score + end +end - ["Impossible Escape"] = function(variant) - if variant and variant.rawText then - return previewFromRawText(variant.rawText, "Impossible Escape (" .. variant.name .. ")") +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 - return previewFinderGroup("Impossible Escape") - end, - - ["Attribute Conversion"] = function(variant) - return previewVariantOrGroup("Attribute Conversion", variant) - end, - - ["Stat Conversion"] = function(variant) - return previewVariantOrGroup("Stat Conversion", variant) - end, - - ["Combat Focus"] = function(variant) - return previewVariantOrGroup("Combat Focus", variant) - end, - - ["Dreams & Nightmares"] = function(variant) - return previewVariantOrGroup("Dreams & Nightmares", variant) - end, - - ["Thread of Hope"] = function(ringName) - return previewThreadOfHope(ringName) - end, -} - -M.jewelPreviewFn = jewelPreviewFn + end + return gained - lost +end --- ───────────────────────────────────────────────────────────────────────────── --- Jewel type definitions --- ───────────────────────────────────────────────────────────────────────────── +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 + jewelType.hasCompute = true + 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 + return jewelType +end function M.buildJewelTypes() - local mightOfTheMeek = { - name = "Might of the Meek", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = getUniqueRadiusIndex("Might of the Meek"), - scoreLabel = "alloc small passives", - hasCompute = true, - rawText = mustGetUniqueRawText("Might of the Meek"), - score = function(nodes, allocNodes) - local s = 0 - for nodeId, node in pairs(nodes) do - if allocNodes[nodeId] and node.type == "Normal" then - s = s + 1 - end - end - return s - end, - } + local scoreAllocatedNormals = scoreAllocatedNodeType("Normal") + local scoreAllocatedNotables = scoreAllocatedNodeType("Notable") + local mightOfTheMeek = makeJewelType("Might of the Meek", "alloc small passives", scoreAllocatedNormals) - local inspiredLearning = { - name = "Inspired Learning", - strategy = JEWEL_STRATEGY.RADIUS, - hasCompute = true, - radiusIndex = getUniqueRadiusIndex("Inspired Learning"), - scoreLabel = "alloc notables", - rawText = mustGetUniqueRawText("Inspired Learning"), - score = function(nodes, allocNodes) - local s = 0 - for nodeId, node in pairs(nodes) do - if allocNodes[nodeId] and node.type == "Notable" then - s = s + 1 - end - end - return s - end, - } + local inspiredLearning = makeJewelType("Inspired Learning", "alloc notables", scoreAllocatedNotables) appendFoulbornVariants(inspiredLearning, "Inspired Learning") - local unnaturalInstinct = { - name = "Unnatural Instinct", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = getUniqueRadiusIndex("Unnatural Instinct"), - scoreLabel = "unalloc small - alloc small", - hasCompute = true, - rawText = mustGetUniqueRawText("Unnatural Instinct"), - score = function(nodes, allocNodes) - local gained, lost = 0, 0 - for nodeId, node in pairs(nodes) do - if node.type == "Normal" then - if allocNodes[nodeId] then lost = lost + 1 - else gained = gained + 1 end - end - end - return gained - lost - end, - } + local unnaturalInstinct = makeJewelType("Unnatural Instinct", "unalloc small - alloc small", scoreUnnaturalInstinct) appendFoulbornVariants(unnaturalInstinct, "Unnatural Instinct") - local lioneyesFall = { - name = "Lioneye's Fall", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = getUniqueRadiusIndex("Lioneye's Fall"), - scoreLabel = "alloc passives", - hasCompute = true, - rawText = mustGetUniqueRawText("Lioneye's Fall"), - score = scoreAllocPassives, - } + local lioneyesFall = makeJewelType("Lioneye's Fall", "alloc passives", scoreAllocPassives) appendFoulbornVariants(lioneyesFall, "Lioneye's Fall") - local intuitiveLeap = { - name = "Intuitive Leap", + local intuitiveLeap = makeJewelType("Intuitive Leap", "unalloc passives", scoreUnallocPassives, { strategy = JEWEL_STRATEGY.INTUITIVE_LEAP, - radiusIndex = getUniqueRadiusIndex("Intuitive Leap"), - scoreLabel = "unalloc passives", - hasCompute = true, computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, - rawText = mustGetUniqueRawText("Intuitive Leap"), - score = function(nodes, allocNodes) - return scoreUnallocPassives(nodes, allocNodes) - end, - } + }) appendFoulbornVariants(intuitiveLeap, "Intuitive Leap") local dreamsNightmaresJewels = { @@ -927,109 +863,54 @@ function M.buildJewelTypes() local threadOfHopeRawText = mustGetUniqueRawText("Thread of Hope") local jewelTypes = { } - t_insert(jewelTypes, { - name = "The Light of Meaning", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = lightOfMeaningVariants[1] and lightOfMeaningVariants[1].radiusIndex, - scoreLabel = "alloc passives", - hasCompute = true, - score = scoreAllocPassives, + t_insert(jewelTypes, makeJewelType("The Light of Meaning", "alloc passives", scoreAllocPassives, { variants = lightOfMeaningVariants, - }) + })) t_insert(jewelTypes, mightOfTheMeek) t_insert(jewelTypes, unnaturalInstinct) t_insert(jewelTypes, inspiredLearning) - t_insert(jewelTypes, { - name = "Anatomical Knowledge", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = getUniqueRadiusIndex("Anatomical Knowledge"), - scoreLabel = "alloc passives", - hasCompute = true, + t_insert(jewelTypes, makeJewelType("Anatomical Knowledge", "alloc passives", scoreAllocPassives, { isLegacy = true, - rawText = mustGetUniqueRawText("Anatomical Knowledge"), - score = scoreAllocPassives, - }) - t_insert(jewelTypes, { - name = "Tempered & Transcendent", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = temperedTranscendentVariants[1] and temperedTranscendentVariants[1].radiusIndex, - scoreLabel = "attr in radius", - hasCompute = true, - score = function(nodes, allocNodes) + })) + t_insert(jewelTypes, makeJewelType("Tempered & Transcendent", "attr in radius", function(nodes, allocNodes) return scoreRadiusAttributes(nodes, allocNodes, "Str", true, false) - end, + end, { variants = temperedTranscendentVariants, - }) + })) t_insert(jewelTypes, lioneyesFall) t_insert(jewelTypes, intuitiveLeap) - t_insert(jewelTypes, { - name = "Impossible Escape", + t_insert(jewelTypes, makeJewelType("Impossible Escape", "unalloc notable/keystone near keystone", + scoreUnallocNotablesAndKeystones, { strategy = JEWEL_STRATEGY.IMPOSSIBLE_ESCAPE, isImpossibleEscape = true, isSocketIndependent = true, - scoreLabel = "unalloc notable/keystone near keystone", - hasCompute = true, computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, - score = scoreUnallocNotablesAndKeystones, variants = M.getImpossibleEscapeVariants(), - }) - t_insert(jewelTypes, { - name = "Split Personality", + })) + t_insert(jewelTypes, makeJewelType("Split Personality", "dist to start", function() return 0 end, { strategy = JEWEL_STRATEGY.SPLIT_PERSONALITY, isSplitPersonality = true, - scoreLabel = "dist to start", - hasCompute = true, - score = function() - return 0 - end, variants = M.getSplitPersonalityVariants(), - }) - t_insert(jewelTypes, { - name = "Stat Conversion", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = statConversionVariants[1] and statConversionVariants[1].radiusIndex, - scoreLabel = "alloc passives", - hasCompute = true, - score = scoreAllocPassives, + })) + t_insert(jewelTypes, makeJewelType("Stat Conversion", "alloc passives", scoreAllocPassives, { variants = statConversionVariants, - }) - t_insert(jewelTypes, { - name = "Attribute Conversion", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = attributeConversionVariants[1] and attributeConversionVariants[1].radiusIndex, - scoreLabel = "alloc passives", - hasCompute = true, - score = scoreAllocPassives, + })) + t_insert(jewelTypes, makeJewelType("Attribute Conversion", "alloc passives", scoreAllocPassives, { variants = attributeConversionVariants, - }) - t_insert(jewelTypes, { - name = "Combat Focus", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = combatFocusVariants[1] and combatFocusVariants[1].radiusIndex, - scoreLabel = "alloc passives", - hasCompute = true, - score = scoreAllocPassives, + })) + t_insert(jewelTypes, makeJewelType("Combat Focus", "alloc passives", scoreAllocPassives, { variants = combatFocusVariants, - }) - t_insert(jewelTypes, { - name = "Dreams & Nightmares", - strategy = JEWEL_STRATEGY.RADIUS, - radiusIndex = dreamsVariants[1] and dreamsVariants[1].radiusIndex, - scoreLabel = "alloc passives", - hasCompute = true, - score = scoreAllocPassives, + })) + t_insert(jewelTypes, makeJewelType("Dreams & Nightmares", "alloc passives", scoreAllocPassives, { variants = dreamsVariants, - }) - t_insert(jewelTypes, { - name = "Thread of Hope", + })) + t_insert(jewelTypes, makeJewelType("Thread of Hope", "unalloc notable/keystone in ring", + scoreUnallocNotablesAndKeystones, { strategy = JEWEL_STRATEGY.THREAD_OF_HOPE, isThread = true, - scoreLabel = "unalloc notable/keystone in ring", - hasCompute = true, computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, rawText = threadOfHopeRawText, - score = scoreUnallocNotablesAndKeystones, - }) + })) for _, jewelType in ipairs(jewelTypes) do assignVariantIdentity(jewelType, jewelType.name, jewelType.name) for _, variant in ipairs(jewelType.variants or { }) do diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index f7e4949000..8d9460419a 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -755,29 +755,12 @@ end -- Open popup -- ───────────────────────────────────────────────────────────────────────────── -local function synchronizeResultCaches(build, finderState) - local outputRevision = build.outputRevision or 0 - if finderState.resultCacheOutputRevision ~= outputRevision then - finderState.findCache = { } - finderState.computeCache = { } - finderState.resultViewByKey = { } - finderState.disconnectedPassivePlanCache = { } - finderState.resultCacheOutputRevision = outputRevision - else - finderState.findCache = finderState.findCache or { } - finderState.computeCache = finderState.computeCache or { } - finderState.resultViewByKey = finderState.resultViewByKey or { } - finderState.disconnectedPassivePlanCache = finderState.disconnectedPassivePlanCache or { } - end -end - local RadiusJewelResultState = { } RadiusJewelResultState.__index = RadiusJewelResultState -function RadiusJewelResultState:new(finder, finderState, computeState, controls) +function RadiusJewelResultState:new(finder, computeState, controls) return setmetatable({ finder = finder, - finderState = finderState, computeState = computeState, controls = controls, }, self) @@ -789,55 +772,6 @@ function RadiusJewelResultState:setResultContext(rows, resultContextKey) end end -function RadiusJewelResultState:restore(resultContextKey, isAllJewels, allJewelsViewId) - local preferredView = self.finderState.resultViewByKey[resultContextKey] - local findCache = not isAllJewels and self.finderState.findCache[resultContextKey] or nil - local computeCache = self.finderState.computeCache[resultContextKey] - local cache = preferredView == "compute" and computeCache or findCache - if not cache and preferredView == "compute" then - cache = findCache - elseif not cache and preferredView == "find" then - cache = computeCache - end - cache = cache or findCache or computeCache - if not cache or cache.resultContextKey ~= resultContextKey then - return false - end - - local rows = copyTableSafe(cache.rows, false, true) - if cache.mode == "computeSocketAll" then - self.computeState.lastComputeAllRows = rows - self.computeState.lastComputeAllResultContextKey = resultContextKey - if allJewelsViewId == "bestPerSocket" then - rows = self.finder:filterBestPerSocket(rows) - end - else - self.computeState.lastComputeAllRows = nil - self.computeState.lastComputeAllResultContextKey = nil - end - self.controls.resultsList:SetMode(cache.mode, rows, cache.defaultText) - self.controls.statusLabel.label = cache.statusLabel or self.controls.statusLabel.label - return true -end - -function RadiusJewelResultState:save(request) - if request.resultContextKey ~= request.currentResultContextKey then - return false - end - local targetCache = request.viewName == "compute" and self.finderState.computeCache or self.finderState.findCache - targetCache[request.resultContextKey] = { - mode = request.mode, - rows = copyTableSafe(request.rows, false, true), - defaultText = request.defaultText, - statusLabel = request.statusLabel, - resultContextKey = request.resultContextKey, - } - if request.makePreferred then - self.finderState.resultViewByKey[request.resultContextKey] = request.viewName - end - return true -end - function RadiusJewelResultState:clear(isAllJewels, canFind) self.computeState.lastComputeAllRows = nil self.computeState.lastComputeAllResultContextKey = nil @@ -850,8 +784,6 @@ function RadiusJewelResultState:clear(isAllJewels, canFind) end function RadiusJewelResultState:showCriteriaChanged(isAllJewels, canFind) - self.computeState.lastComputeAllRows = nil - self.computeState.lastComputeAllResultContextKey = nil local message = (isAllJewels or not canFind) and "^xFFAA33Criteria changed. ^8Run Compute again." or "^xFFAA33Criteria changed. ^8Run Find or Compute again." @@ -861,24 +793,6 @@ function RadiusJewelResultState:showCriteriaChanged(isAllJewels, canFind) end end -function RadiusJewelResultState:rememberVisibleView(resultContextKey) - local mode = self.controls.resultsList.mode - local viewName = (mode == "find" or mode == "findThread") and "find" - or (mode == "computeSocket" or mode == "computeSocketAll") and "compute" - if not viewName then - return - end - local cache - if viewName == "compute" then - cache = self.finderState.computeCache[resultContextKey] - else - cache = self.finderState.findCache[resultContextKey] - end - if cache and cache.resultContextKey == resultContextKey then - self.finderState.resultViewByKey[resultContextKey] = viewName - end -end - function RadiusJewelResultState:isApplicable(row, currentResultContextKey) return row ~= nil and row.actionPlan ~= nil and row.resultContextKey == currentResultContextKey and isActionPlanCurrent(self.finder.build, row.actionPlan) @@ -1280,7 +1194,6 @@ local function buildRadiusJewelPopupSetup(self) local finderState = self.build.radiusJewelFinderState or { } self.build.radiusJewelFinderState = finderState - synchronizeResultCaches(self.build, finderState) local allJewelsViewOptions = { { id = "all", label = "All results" }, @@ -1563,7 +1476,7 @@ local function computeJewelType(self, jewelType, request) return strategy.compute(self.compute, jewelType, request) end -local function runRadiusJewelFind(self, context, makePreferred) +local function runRadiusJewelFind(self, context) local controls = context.controls local treeData = context.treeData local radiusIndexByLabel = context.radiusIndexByLabel @@ -1576,16 +1489,12 @@ local function runRadiusJewelFind(self, context, makePreferred) local resultContextKey = context.resultContextKey local getSelectedVariants = context.getSelectedVariants local formatElapsed = context.formatElapsed - local restoreCachedResults = context.restoreCachedResults - local saveResultCache = context.saveResultCache local setResultContext = context.setResultContext local showAllJewelsComputePrompt = context.showAllJewelsComputePrompt local searchStartTime = GetTime() if selectedJewelType and selectedJewelType.isAllJewels then - if not restoreCachedResults() then - showAllJewelsComputePrompt() - end + showAllJewelsComputePrompt() return end controls.statusLabel.label = "^7Searching..." @@ -1697,10 +1606,6 @@ local function runRadiusJewelFind(self, context, makePreferred) controls.statusLabel.label = (strategy.formatFindStatus and strategy.formatFindStatus(findRequest, #results) or s_format("^7%d results | score/pt", #results)) .. elapsed - saveResultCache("find", resultMode, rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred, resultContextKey) - if not makePreferred then - restoreCachedResults() - end end) if not ok then controls.statusLabel.label = "^1Search failed" @@ -1714,7 +1619,6 @@ local function runRadiusJewelCompute(self, context) local controls = context.controls local computeState = context.computeState local cancelCompute = context.cancelCompute - local restoreCachedResults = context.restoreCachedResults local setComputeProgress = context.setComputeProgress local makeComputeProgressTracker = context.makeComputeProgressTracker local selectedImpactStat = context.selectedImpactStat @@ -1724,14 +1628,12 @@ local function runRadiusJewelCompute(self, context) local activeJewelTypes = context.activeJewelTypes local jewelSockets = context.jewelSockets local threadVariants = context.threadVariants - local finderState = context.finderState 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 saveResultCache = context.saveResultCache local setResultContext = context.setResultContext local getSelectedVariants = context.getSelectedVariants local hasVariantGroups = context.hasVariantGroups @@ -1741,12 +1643,15 @@ local function runRadiusJewelCompute(self, context) if computeState.computeContext then cancelCompute("^8Compute stopped") - restoreCachedResults() + 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 = { @@ -1763,7 +1668,7 @@ local function runRadiusJewelCompute(self, context) threadVariants = threadVariants, impactStat = selectedImpactStat, methodId = computeMethod.id, - planCache = finderState.disconnectedPassivePlanCache, + planCache = planCache, progress = computeProgress, maxTotalPoints = selectedMaxPoints, occupiedMode = selectedOccupiedMode, @@ -1896,7 +1801,6 @@ local function runRadiusJewelCompute(self, context) 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) - saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true, resultContextKey) else local displayedVariants = getSelectedVariants() local strategy = getJewelStrategy(selectedJewelType) @@ -1931,7 +1835,6 @@ local function runRadiusJewelCompute(self, context) setResultContext(rows, resultContextKey) controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)") controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime) - saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true, resultContextKey) end end) if not ok then @@ -2022,7 +1925,7 @@ local function buildRadiusJewelPopupContext(self) local allJewelsViewLabels = setup.allJewelsViewLabels local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1] local computeState = { } - local resultState = RadiusJewelResultState:new(self, finderState, computeState, controls) + local resultState = RadiusJewelResultState:new(self, computeState, controls) local suppressFinderStateSave = false local runFind @@ -2064,7 +1967,6 @@ local function buildRadiusJewelPopupContext(self) end local function getResultContextKey() - synchronizeResultCaches(self.build, finderState) local selectedVariantIdentity = selectedJewelVariant and selectedJewelVariant.variantIdentity local selectedVariantKey = selectedVariantIdentity and selectedVariantIdentity.rawText or selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) @@ -2094,47 +1996,20 @@ local function buildRadiusJewelPopupContext(self) resultState:setResultContext(rows, resultContextKey) end - local function restoreCachedResults(resultContextKey) - local key = resultContextKey or getResultContextKey() - return resultState:restore(key, - selectedJewelType and selectedJewelType.isAllJewels, - selectedAllJewelsView.id) - end - local function saveResultCache(viewName, mode, rows, defaultText, statusLabel, makePreferred, resultContextKey) - local key = resultContextKey or getResultContextKey() - return resultState:save({ - viewName = viewName, - mode = mode, - rows = rows, - defaultText = defaultText, - statusLabel = statusLabel, - makePreferred = makePreferred, - resultContextKey = key, - currentResultContextKey = getResultContextKey(), - }) - end local function clearResultsForContext() resultState:clear(selectedJewelType and selectedJewelType.isAllJewels, canFindCurrentSelection()) end local function showCriteriaChangedForContext() resultState:showCriteriaChanged(selectedJewelType and selectedJewelType.isAllJewels, canFindCurrentSelection()) end - local function saveVisibleResultView(resultContextKey) - resultState:rememberVisibleView(resultContextKey) - end local function isResultContextCurrent(resultContextKey) return resultContextKey == getResultContextKey() end local function onCriteriaChanged(updateCriteria) cancelCompute() - local previousResultContextKey = getResultContextKey() - saveVisibleResultView(previousResultContextKey) updateCriteria() saveFinderState() - local resultContextKey = getResultContextKey() - if not restoreCachedResults(resultContextKey) then - showCriteriaChangedForContext() - end + showCriteriaChangedForContext() end local function formatComputeStatus(itemLabel, statLabel, baseline, methodLabel) if methodLabel and methodLabel ~= "" then @@ -2489,8 +2364,6 @@ local function buildRadiusJewelPopupContext(self) local displayRows = selectedAllJewelsView.id == "bestPerSocket" and self:filterBestPerSocket(computeState.lastComputeAllRows) or computeState.lastComputeAllRows controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)") - elseif computeState.lastComputeAllRows then - clearResultsForContext() end saveFinderState() end) @@ -2879,7 +2752,6 @@ local function buildRadiusJewelPopupContext(self) controls = controls, computeState = computeState, cancelCompute = cancelCompute, - restoreCachedResults = restoreCachedResults, setComputeProgress = setComputeProgress, makeComputeProgressTracker = makeComputeProgressTracker, selectedImpactStat = selectedImpactStat, @@ -2889,14 +2761,12 @@ local function buildRadiusJewelPopupContext(self) activeJewelTypes = activeJewelTypes, jewelSockets = jewelSockets, threadVariants = selectedThreadVariants, - finderState = finderState, selectedMaxPoints = selectedMaxPoints, selectedOccupiedMode = selectedOccupiedMode, buildComputeRows = buildComputeRows, getSelectedAllJewelsView = function() return selectedAllJewelsView end, formatComputeStatus = formatComputeStatus, formatElapsed = formatElapsed, - saveResultCache = saveResultCache, setResultContext = setResultContext, getSelectedVariants = getSelectedVariants, hasVariantGroups = hasVariantGroups, @@ -2911,7 +2781,7 @@ local function buildRadiusJewelPopupContext(self) tooltip:Clear(true) if computeState.computeContext then tooltip:AddLine(16, "^7Stop the current compute.") - tooltip:AddLine(16, "^8Restores the previous results.") + tooltip:AddLine(16, "^8Run Compute again to refresh the results.") return end if selectedJewelType and selectedJewelType.isAllJewels then @@ -2936,7 +2806,7 @@ local function buildRadiusJewelPopupContext(self) end) end) - runFind = function(makePreferred) + runFind = function() local resultContextKey = getResultContextKey() runRadiusJewelFind(self, { controls = controls, @@ -2951,15 +2821,13 @@ local function buildRadiusJewelPopupContext(self) resultContextKey = resultContextKey, getSelectedVariants = getSelectedVariants, formatElapsed = formatElapsed, - restoreCachedResults = restoreCachedResults, - saveResultCache = saveResultCache, setResultContext = setResultContext, showAllJewelsComputePrompt = showAllJewelsComputePrompt, - }, makePreferred) + }) end controls.findButton = new("ButtonControl"):ButtonControl(BL, { edgePadding, bottomButtonY, 100, buttonHeight }, "Find", function() cancelCompute() - runFind(true) + runFind() end) controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels) controls.findButton.enabled = canFindCurrentSelection @@ -3077,9 +2945,7 @@ local function buildRadiusJewelPopupContext(self) suppressFinderStateSave = false saveFinderState() - if not restoreCachedResults() then - clearResultsForContext() - end + clearResultsForContext() end controls.closeButton = new("ButtonControl"):ButtonControl(BR, { -edgePadding, bottomButtonY, 100, buttonHeight }, "Close", function() diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua index f0702f9423..37e70eb5ec 100644 --- a/src/Classes/RadiusJewelResultsListControl.lua +++ b/src/Classes/RadiusJewelResultsListControl.lua @@ -40,28 +40,31 @@ local function colorSocketLabel(row) return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel end -local RESULT_DETAIL_COLUMN_BY_MODE = { - computeSocket = 6, - computeSocketAll = 7, - find = 5, - findThread = 6, -} -local RESULT_SOCKET_COLUMN_BY_MODE = { - computeSocket = 1, - computeSocketAll = 2, - find = 1, - findThread = 1, -} -local RESULT_STAT_COLUMNS_BY_MODE = { - computeSocket = { [3] = true, [4] = true, [5] = true }, - computeSocketAll = { [4] = true, [5] = true, [6] = true }, -} -local RESULT_ITEM_COLUMNS_BY_MODE = { - computeSocket = { [6] = true }, - computeSocketAll = { [7] = true }, - find = { [5] = true }, - findThread = { [6] = true }, -} +local 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") @@ -76,39 +79,39 @@ function RadiusJewelResultsListClass:RadiusJewelResultsListControl(anchor, rect, self.mode = "message" self.columnsByMode = { message = { - { width = rect[3] - 22, label = "" }, + column(rect[3] - 22, "", function(row) return row.text or "" end), }, computeSocket = { - { width = 170, label = "Socket", sortable = true }, - { width = 50, label = "Points", sortable = true }, - { width = 75, label = "Gain", sortable = true }, - { width = 60, label = "%", sortable = true }, - { width = 65, label = "%/Pt", sortable = true }, - { width = 140, label = "Detail", sortable = true }, + 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 = { - { width = 120, label = "Jewel", sortable = true }, - { width = 130, label = "Socket", sortable = true }, - { width = 50, label = "Points", sortable = true }, - { width = 75, label = "Gain", sortable = true }, - { width = 60, label = "%", sortable = true }, - { width = 65, label = "%/Pt", sortable = true }, - { width = 60, label = "Detail", sortable = true }, + 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 = { - { width = 170, label = "Socket", sortable = true }, - { width = 50, label = "Points", sortable = true }, - { width = 60, label = "Score", sortable = true }, - { width = 70, label = "/Pt", sortable = true }, - { width = 210, label = "Detail", sortable = true }, + 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 = { - { width = 170, label = "Socket", sortable = true }, - { width = 50, label = "Points", sortable = true }, - { width = 60, label = "Score", sortable = true }, - { width = 70, label = "/Pt", sortable = true }, - { width = 90, label = "Ring", sortable = true }, - { width = 120, label = "Detail", sortable = true }, + 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 = { @@ -144,13 +147,15 @@ function RadiusJewelResultsListClass:SetMode(mode, list, defaultText) end function RadiusJewelResultsListClass:GetHoverInfo(hoverColumn, hoverData) - local detailColumn = hoverColumn and RESULT_DETAIL_COLUMN_BY_MODE[self.mode] == hoverColumn - local socketColumn = hoverColumn and RESULT_SOCKET_COLUMN_BY_MODE[self.mode] == hoverColumn + local 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 hoverColumn and RESULT_STAT_COLUMNS_BY_MODE[self.mode] and RESULT_STAT_COLUMNS_BY_MODE[self.mode][hoverColumn] + and hoverRole == "stat" local showItemTooltip = hoverData and hoverData.itemTooltipLines - and hoverColumn and RESULT_ITEM_COLUMNS_BY_MODE[self.mode] and RESULT_ITEM_COLUMNS_BY_MODE[self.mode][hoverColumn] + and detailColumn local hoverNodeId = hoverData and hoverData.socketId or nil if hoverData and hoverData.detailNodeId and detailColumn then hoverNodeId = hoverData.detailNodeId @@ -166,94 +171,15 @@ function RadiusJewelResultsListClass:GetHoverInfo(hoverColumn, hoverData) end function RadiusJewelResultsListClass:ReSort(colIndex) - if self.mode == "computeSocket" then - if colIndex == 1 then - t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) - elseif colIndex == 2 then - t_sort(self.list, function(a, b) return a.points < b.points end) - elseif colIndex == 3 then - t_sort(self.list, function(a, b) return a.delta > b.delta end) - elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.pct > b.pct end) - elseif colIndex == 5 then - t_sort(self.list, function(a, b) return a.sortValue > b.sortValue end) - elseif colIndex == 6 then - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - elseif self.mode == "computeSocketAll" then - if colIndex == 1 then - t_sort(self.list, function(a, b) return a.jewelName < b.jewelName end) - elseif colIndex == 2 then - t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) - elseif colIndex == 3 then - t_sort(self.list, function(a, b) return a.points < b.points end) - elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.delta > b.delta end) - elseif colIndex == 5 then - t_sort(self.list, function(a, b) return a.pct > b.pct end) - elseif colIndex == 6 then - t_sort(self.list, function(a, b) return a.sortValue > b.sortValue end) - elseif colIndex == 7 then - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - elseif self.mode == "find" or self.mode == "findThread" then - if colIndex == 1 then - t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end) - elseif colIndex == 2 then - t_sort(self.list, function(a, b) return a.points < b.points end) - elseif colIndex == 3 then - t_sort(self.list, function(a, b) return a.score > b.score end) - elseif colIndex == 4 then - t_sort(self.list, function(a, b) return a.sortValue > b.sortValue end) - elseif colIndex == 5 then - if self.mode == "findThread" then - t_sort(self.list, function(a, b) return a.variantLabel < b.variantLabel end) - else - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end - elseif colIndex == 6 and self.mode == "findThread" then - t_sort(self.list, function(a, b) return a.detailText < b.detailText end) - end + 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) - if self.mode == "message" then - return column == 1 and row.text or "" - elseif self.mode == "computeSocket" then - return column == 1 and colorSocketLabel(row) - or column == 2 and tostring(row.points) - or column == 3 and formatSignedValue(row.delta) - or column == 4 and formatSignedPercent(row.pct) - or column == 5 and formatPerPointDisplay(row.pctPerPoint, row.points) - or column == 6 and row.detailText - or "" - elseif self.mode == "computeSocketAll" then - return column == 1 and row.jewelName - or column == 2 and colorSocketLabel(row) - or column == 3 and tostring(row.points) - or column == 4 and formatSignedValue(row.delta) - or column == 5 and formatSignedPercent(row.pct) - or column == 6 and formatPerPointDisplay(row.pctPerPoint, row.points) - or column == 7 and row.detailText - or "" - elseif self.mode == "find" then - return column == 1 and colorSocketLabel(row) - or column == 2 and tostring(row.points) - or column == 3 and s_format("^7%d", row.score) - or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) - or column == 5 and row.detailText - or "" - elseif self.mode == "findThread" then - return column == 1 and colorSocketLabel(row) - or column == 2 and tostring(row.points) - or column == 3 and s_format("^7%d", row.score) - or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)) - or column == 5 and row.variantLabel - or column == 6 and row.detailText - or "" - end - return "" + local columnInfo = self.colList[column] + return columnInfo and columnInfo.getValue and columnInfo.getValue(row) or "" end function RadiusJewelResultsListClass:Draw(viewPort, noTooltip) From 77893e9675fd53917a156c79c0834afe8ac80607 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 12:59:32 +0200 Subject: [PATCH 48/52] Extract radius jewel item actions Separate guarded item-plan construction and execution from popup orchestration while preserving existing action and Undo behavior. --- manifest.xml | 3 +- spec/System/TestRadiusJewelActions_spec.lua | 48 +-- src/Classes/RadiusJewelFinder.lua | 359 +------------------ src/Classes/RadiusJewelItemActions.lua | 372 ++++++++++++++++++++ 4 files changed, 407 insertions(+), 375 deletions(-) create mode 100644 src/Classes/RadiusJewelItemActions.lua diff --git a/manifest.xml b/manifest.xml index 75fafe64d4..cfb2a89e5b 100644 --- a/manifest.xml +++ b/manifest.xml @@ -174,7 +174,8 @@ - + + diff --git a/spec/System/TestRadiusJewelActions_spec.lua b/spec/System/TestRadiusJewelActions_spec.lua index 3931417174..d1ad2aad94 100644 --- a/spec/System/TestRadiusJewelActions_spec.lua +++ b/spec/System/TestRadiusJewelActions_spec.lua @@ -218,7 +218,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local beforeAllocatedNodes = allocatedNodeIds() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Free target", targetIdentity = jewelType.variantIdentity, @@ -229,7 +229,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.is_nil(plan.sourceItemId) assert.are.equal(jewelType.variantIdentity, plan.targetIdentity) assert.are.equal(jewelType.rawText, plan.targetRawText) - assert.is_true(finder:executeActionPlan(plan)) + 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) @@ -248,7 +248,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local undoCount = #build.itemsTab.undo local itemCount = #build.itemsTab.itemOrderList local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Free target", targetIdentity = jewelType.variantIdentity, @@ -256,7 +256,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() }) assert.is_false(plan.targetSocketAllocated) - assert.is_true(finder:executeAddToBuildPlan(plan)) + 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] @@ -274,7 +274,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Free target", targetIdentity = jewelType.variantIdentity, @@ -282,7 +282,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() }) assert.are.equal(existingItem.id, plan.sourceItemId) - assert.is_false(finder:executeAddToBuildPlan(plan)) + assert.is_false(finder.itemActions:executeAddToBuildPlan(plan)) assert.are.equal(undoCount, #build.itemsTab.undo) support.assertFinderStateUnchanged(before, assert) end) @@ -307,7 +307,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Variant target", targetIdentity = foulbornVariant.variantIdentity, @@ -316,7 +316,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal(sourceItem.id, plan.sourceItemId) assert.is_false(plan.sourceMatchesTarget) - assert.is_true(finder:executeAddToBuildPlan(plan)) + 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] @@ -408,7 +408,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Exact target", targetIdentity = jewelType.variantIdentity, @@ -417,7 +417,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal("equipped", plan.kind) assert.are.equal(item.id, plan.sourceItemId) - assert.is_false(finder:executeActionPlan(plan)) + assert.is_false(finder.itemActions:executePlan(plan)) assert.are.equal(undoCount, #build.itemsTab.undo) support.assertFinderStateUnchanged(before, assert) end) @@ -431,7 +431,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Occupied target", targetIdentity = jewelType.variantIdentity, @@ -440,7 +440,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal("replace", plan.kind) assert.are.equal(replacedItem.id, plan.replacedTargetId) - assert.is_true(finder:executeActionPlan(plan)) + 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) @@ -544,7 +544,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Allocated target", targetIdentity = jewelType.variantIdentity, @@ -555,7 +555,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal(sourceSocketId, plan.sourceSocketId) assert.are.equal(sourceItem.id, plan.sourceItemId) assert.is_true(plan.sourceMatchesTarget) - assert.is_true(finder:executeActionPlan(plan)) + 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) @@ -572,7 +572,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() build.itemsTab.sockets[targetSocketId]:SetSelItemId(0) build.itemsTab:ResetUndo() local finder = support.makeFinder() - local equipPlan = finder:buildActionPlan({ + local equipPlan = finder.itemActions:buildPlan({ socketId = sourceSocketId, socketLabel = "Unallocated source", targetIdentity = variant.variantIdentity, @@ -580,10 +580,10 @@ describe("RadiusJewelFinder actions #radius-jewel", function() }) assert.are.equal("equip", equipPlan.kind) - assert.is_true(finder:executeActionPlan(equipPlan)) + assert.is_true(finder.itemActions:executePlan(equipPlan)) local itemId = build.itemsTab.sockets[sourceSocketId].selItemId assert.is_true(itemId ~= 0) - local movePlan = finder:buildActionPlan({ + local movePlan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Unallocated destination", targetIdentity = variant.variantIdentity, @@ -591,7 +591,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() }) assert.are.equal("move", movePlan.kind) - assert.is_true(finder:executeActionPlan(movePlan)) + 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) @@ -618,7 +618,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Allocated target", targetIdentity = jewelType.variantIdentity, @@ -628,7 +628,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal("move", plan.kind) assert.are.equal(storedSourceSocketId, plan.sourceSocketId) assert.are.equal(storedItem.id, plan.sourceItemId) - assert.is_true(finder:executeActionPlan(plan)) + 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) @@ -677,7 +677,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Variant target", targetIdentity = foulbornVariant.variantIdentity, @@ -693,7 +693,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal("replace", plan.kind) assert.are.equal(sourceItem.id, plan.sourceItemId) assert.is_false(plan.sourceMatchesTarget) - assert.is_true(finder:executeActionPlan(plan)) + 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) @@ -727,7 +727,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() local before = support.snapshotFinderState() local undoCount = #build.itemsTab.undo local finder = support.makeFinder() - local plan = finder:buildActionPlan({ + local plan = finder.itemActions:buildPlan({ socketId = targetSocketId, socketLabel = "Variant target", targetIdentity = foulbornVariant.variantIdentity, @@ -737,7 +737,7 @@ describe("RadiusJewelFinder actions #radius-jewel", function() assert.are.equal("move", plan.kind) assert.are.equal(sourceItem.id, plan.sourceItemId) assert.is_false(plan.sourceMatchesTarget) - assert.is_true(finder:executeActionPlan(plan)) + 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) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 8d9460419a..5333ad52d6 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -14,6 +14,7 @@ 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 @@ -31,6 +32,7 @@ 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 @@ -192,349 +194,6 @@ function RadiusJewelFinderClass:findEquippedJewelSockets(jewelType, variant) return equipped end ----@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? - -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 - -local function 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 - -local function findCanonicalBuildItem(itemsTab, targetCanonicalKey) - 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 - -local function findExactStoredSource(itemsTab, allocNodes, targetCanonicalKey, targetSocketId) - 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 RadiusJewelFinderClass:buildActionPlan(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 getSocketLabel(targetSocket, target.socketId) - local targetSocketAllocated = self.build.spec.allocNodes[target.socketId] ~= nil - local _, _, _, matchingItemsStateKey = findCanonicalBuildItem(self.build.itemsTab, 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: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 = findExactStoredSource( - self.build.itemsTab, self.build.spec.allocNodes, 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 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 - -local function isActionPlanCurrent(build, plan) - local itemsTab = build.itemsTab - local targetSocket = plan and itemsTab.sockets[plan.targetSocketId] - if not targetSocket or targetSocket.selItemId ~= plan.targetItemId then - return false - end - if (build.spec.allocNodes[plan.targetSocketId] ~= nil) ~= plan.targetSocketAllocated then - return false - end - local _, _, _, matchingItemsStateKey = findCanonicalBuildItem(itemsTab, 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 RadiusJewelFinderClass:executeActionPlan(plan) - local itemsTab = self.build.itemsTab - if not isActionPlanCurrent(self.build, 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 RadiusJewelFinderClass:executeAddToBuildPlan(plan) - local itemsTab = self.build.itemsTab - if not isActionPlanCurrent(self.build, plan) then - return false - end - local existingItem = findCanonicalBuildItem(itemsTab, 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 - -- 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. @@ -795,7 +454,7 @@ end function RadiusJewelResultState:isApplicable(row, currentResultContextKey) return row ~= nil and row.actionPlan ~= nil and row.resultContextKey == currentResultContextKey - and isActionPlanCurrent(self.finder.build, row.actionPlan) + and self.finder.itemActions:isPlanCurrent(row.actionPlan) end local ACTION_LABELS = { @@ -830,12 +489,12 @@ function RadiusJewelResultActions:getMatchingBuildItem(row) if not row or not row.actionPlan then return nil end - return findCanonicalBuildItem(self.finder.build.itemsTab, row.actionPlan.targetCanonicalKey) + 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:executeActionPlan(row.actionPlan) + self.finder.itemActions:executePlan(row.actionPlan) end end @@ -865,7 +524,7 @@ end function RadiusJewelResultActions:addSelectedToBuild() local row = self:getSelectedRow() if self:isApplicable(row) then - self.finder:executeAddToBuildPlan(row.actionPlan) + self.finder.itemActions:executeAddToBuildPlan(row.actionPlan) end end @@ -889,7 +548,7 @@ function RadiusJewelResultActions:addToBuildTooltip(tooltip) local itemName = plan.targetIdentity.uniqueName or row.jewelName or "jewel" local existingItem, existingSocket, existingSocketId = self:getMatchingBuildItem(row) if existingItem then - local location = existingSocketId and getSocketLabel(existingSocket, existingSocketId) or "Items" + 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.") @@ -1573,7 +1232,7 @@ local function runRadiusJewelFind(self, context) or r.variant and r.variant.rawText or selectedJewelVariant and selectedJewelVariant.rawText or selectedJewelType.rawText - local actionPlan = self:buildActionPlan({ + local actionPlan = self.itemActions:buildPlan({ socketId = r.socket.id, socketLabel = r.socket.label, targetIdentity = targetIdentity, @@ -2701,7 +2360,7 @@ local function buildRadiusJewelPopupContext(self) local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] detailNodeId = keystoneNode and keystoneNode.id or nil end - local actionPlan = self:buildActionPlan({ + local actionPlan = self.itemActions:buildPlan({ socketId = r.socket.id, socketLabel = r.socket.label, targetIdentity = variantIdentity, 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, +} From 599d158afecd2f1caa4bca211eb2bf20c64f5c1a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 13:19:19 +0200 Subject: [PATCH 49/52] Clarify radius jewel result contracts Name Impossible Escape effect independence precisely and remove redundant row aliases in favor of the shared action plan. --- manifest.xml | 4 ++-- spec/System/TestRadiusJewelCompute_spec.lua | 22 +++++++++---------- spec/System/TestRadiusJewelFinder_spec.lua | 4 ++-- src/Classes/RadiusJewelData.lua | 2 +- src/Classes/RadiusJewelFinder.lua | 24 +++++++++------------ 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/manifest.xml b/manifest.xml index cfb2a89e5b..1c888c8dfc 100644 --- a/manifest.xml +++ b/manifest.xml @@ -172,9 +172,9 @@ - + - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index d5b54d9aaf..12fd4108ee 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -1189,7 +1189,7 @@ describe("RadiusJewelCompute #radius-jewel", function() return { socketId = socketId, sortValue = score, - isSocketIndependent = options.isSocketIndependent, + isEffectSocketIndependent = options.isEffectSocketIndependent, jewelLimitKey = options.jewelLimitKey, jewelLimit = options.jewelLimit, points = options.points, @@ -1256,8 +1256,8 @@ describe("RadiusJewelCompute #radius-jewel", function() -- The dependent should get socket 1, independent goes to socket 2 local rows = { makeRow(1, 10, { name = "dependent" }), - makeRow(1, 20, { name = "independent", isSocketIndependent = true }), - makeRow(2, 5, { name = "independent2", isSocketIndependent = true }), + makeRow(1, 20, { name = "independent", isEffectSocketIndependent = true }), + makeRow(2, 5, { name = "independent2", isEffectSocketIndependent = true }), } local result = makeFinder():filterBestPerSocket(rows) assert.are.equal(2, #result) @@ -1272,9 +1272,9 @@ describe("RadiusJewelCompute #radius-jewel", function() local rows = { makeRow(1, 30, { name = "dependent-1" }), makeRow(2, 25, { name = "dependent-2" }), - makeRow(1, 20, { name = "independent-1", isSocketIndependent = true }), - makeRow(2, 15, { name = "independent-2", isSocketIndependent = true }), - makeRow(3, 10, { name = "independent-3", isSocketIndependent = true }), + 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 = {} @@ -1286,8 +1286,8 @@ describe("RadiusJewelCompute #radius-jewel", function() it("socket-independent tie-break uses fewer points", function() local rows = { - makeRow(1, 20, { isSocketIndependent = true, points = 5 }), - makeRow(2, 20, { isSocketIndependent = true, points = 2 }), + makeRow(1, 20, { isEffectSocketIndependent = true, points = 5 }), + makeRow(2, 20, { isEffectSocketIndependent = true, points = 2 }), } local result = makeFinder():filterBestPerSocket(rows) assert.are.equal(2, #result) @@ -1300,8 +1300,8 @@ describe("RadiusJewelCompute #radius-jewel", function() -- Two independent jewels can use a single remaining socket local rows = { makeRow(1, 50, { name = "dependent" }), -- takes socket 1 - makeRow(1, 20, { name = "ie-high-points", isSocketIndependent = true, points = 8 }), - makeRow(2, 20, { name = "ie-low-points", isSocketIndependent = true, points = 2 }), + 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 = {} @@ -1315,7 +1315,7 @@ describe("RadiusJewelCompute #radius-jewel", function() -- independent rows with that key are blocked local rows = { makeRow(1, 30, { name = "dependent-ie", jewelLimitKey = "IE", jewelLimit = 1 }), - makeRow(2, 20, { name = "independent-ie", isSocketIndependent = true, jewelLimitKey = "IE", jewelLimit = 1 }), + makeRow(2, 20, { name = "independent-ie", isEffectSocketIndependent = true, jewelLimitKey = "IE", jewelLimit = 1 }), makeRow(3, 10, { name = "other" }), } local result = makeFinder():filterBestPerSocket(rows) diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua index 4fb5217052..689db254fc 100644 --- a/spec/System/TestRadiusJewelFinder_spec.lua +++ b/spec/System/TestRadiusJewelFinder_spec.lua @@ -735,9 +735,9 @@ describe("RadiusJewelFinder #radius-jewel", function() 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.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].applyRawText) 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()) @@ -758,9 +758,9 @@ describe("RadiusJewelFinder #radius-jewel", function() runCallback("OnFrame") end assert.are.equal(1, #popup.controls.resultsList.list) - assert.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].applyRawText) 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()) diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 531387e664..343c44393a 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -883,7 +883,7 @@ function M.buildJewelTypes() scoreUnallocNotablesAndKeystones, { strategy = JEWEL_STRATEGY.IMPOSSIBLE_ESCAPE, isImpossibleEscape = true, - isSocketIndependent = true, + isEffectSocketIndependent = true, computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, variants = M.getImpossibleEscapeVariants(), })) diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index 5333ad52d6..de0882c407 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -342,12 +342,12 @@ local buildDisplayedDisconnectedPassivePlans = RadiusJewelCompute.buildDisplayed -- ───────────────────────────────────────────────────────────────────────────── --- Filter rows to keep at most one result per socket while applying jewel limits ---- and use socket-dependent jewels before socket-independent ones. +--- 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) ---- isSocketIndependent (boolean?) – true for jewels like IE +--- 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) @@ -364,7 +364,7 @@ function RadiusJewelFinderClass:filterBestPerSocket(rows) local filtered = { } -- Pass 1: assign socket-dependent jewels first (they need specific sockets) for _, row in ipairs(sorted) do - if not row.isSocketIndependent and not usedSockets[row.socketId] then + 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 @@ -376,10 +376,10 @@ function RadiusJewelFinderClass:filterBestPerSocket(rows) end end end - -- Pass 2: assign socket-independent jewels (e.g. IE) to remaining sockets, fewer points first + -- Pass 2: assign socket-independent effects (Impossible Escape) to remaining sockets, fewer points first local independentSorted = { } for _, row in ipairs(sorted) do - if row.isSocketIndependent then + if row.isEffectSocketIndependent then t_insert(independentSorted, row) end end @@ -1254,8 +1254,6 @@ local function runRadiusJewelFind(self, context) storedUnallocatedItemLabel = r.storedUnallocatedItemLabel, action = actionPlan and actionPlan.kind or nil, actionPlan = actionPlan, - targetIdentity = targetIdentity, - applyRawText = targetRawText, }) end setResultContext(rows, resultContextKey) @@ -2313,14 +2311,14 @@ local function buildRadiusJewelPopupContext(self) 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 applyRawText = variantIdentity and variantIdentity.rawText or r.variant and r.variant.rawText or jewelType.rawText + local targetRawText = variantIdentity and variantIdentity.rawText or r.variant and r.variant.rawText or jewelType.rawText local jewelLimitKey = variantIdentity and variantIdentity.limitKey - or applyRawText and applyRawText:match("^([^\n]+)") + or targetRawText and targetRawText:match("^([^\n]+)") or jewelType.name jewelLimitKey = jewelLimitKey:gsub("^[Ff]oulborn ", "") local jewelLimit = variantIdentity and variantIdentity.limit or jewelType.limit - or (applyRawText and tonumber(applyRawText:match("Limited to: (%d+)"))) + or (targetRawText and tonumber(targetRawText:match("Limited to: (%d+)"))) or nil local displayedPlans = (jewelType.name == "Intuitive Leap" or jewelType.isThread or jewelType.isImpossibleEscape) and buildDisplayedDisconnectedPassivePlans(r, points, baseline) @@ -2364,7 +2362,7 @@ local function buildRadiusJewelPopupContext(self) socketId = r.socket.id, socketLabel = r.socket.label, targetIdentity = variantIdentity, - targetRawText = applyRawText, + targetRawText = targetRawText, }) t_insert(rows, { socketLabel = r.socket.label, @@ -2387,11 +2385,9 @@ local function buildRadiusJewelPopupContext(self) jewelName = jewelType.name, jewelLimitKey = jewelLimitKey, jewelLimit = jewelLimit, - isSocketIndependent = jewelType.isSocketIndependent, - applyRawText = applyRawText, + isEffectSocketIndependent = jewelType.isEffectSocketIndependent, action = actionPlan and actionPlan.kind or nil, actionPlan = actionPlan, - targetIdentity = variantIdentity, tooltipHeader = jewelType.isThread and "^7Socketing this jewel and allocating the best ring plan here will give you:" or jewelType.name == "Intuitive Leap" and "^7Socketing this jewel and allocating the best nodes here will give you:" or jewelType.isImpossibleEscape and "^7Socketing this jewel and allocating the best keystone plan here will give you:" From d1ca4eedc5fe0b1eef12cf681a248fdd33c917d2 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 16:02:38 +0200 Subject: [PATCH 50/52] Consolidate radius jewel strategy capabilities Make jewel descriptors the single preview catalog and strategies the single owner of finder capabilities. Preserve preview and result behavior while removing duplicate flags and metadata. --- manifest.xml | 4 +- spec/System/TestRadiusJewelData_spec.lua | 43 +++++- src/Classes/RadiusJewelData.lua | 61 +++------ src/Classes/RadiusJewelFinder.lua | 166 +++++++++++++---------- 4 files changed, 148 insertions(+), 126 deletions(-) diff --git a/manifest.xml b/manifest.xml index 1c888c8dfc..fe4baac0c1 100644 --- a/manifest.xml +++ b/manifest.xml @@ -172,9 +172,9 @@ - + - + diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua index 917f11d882..514bd52fb7 100644 --- a/spec/System/TestRadiusJewelData_spec.lua +++ b/spec/System/TestRadiusJewelData_spec.lua @@ -54,14 +54,41 @@ describe("RadiusJewelData #radius-jewel", function() -- ── buildJewelTypes ────────────────────────────────────────────────────── describe("buildJewelTypes", function() - it("registers previews only for known jewel types", function() - assert.is_nil(RadiusJewelData.jewelPreviewFn["Unknown Radius Jewel"]) + it("gives every jewel descriptor its preview", function() for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do - assert.is_function(RadiusJewelData.jewelPreviewFn[jewelType.name], + 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 = { @@ -343,7 +370,15 @@ describe("RadiusJewelData #radius-jewel", function() assert.is_true(variant.keystoneOnly) assert.are.same({ "Massive Radius", "Keystone Passive Skills only" }, variant.previewMeta) - local preview = RadiusJewelData.jewelPreviewFn["Intuitive Leap"](variant) + 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 diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua index 343c44393a..0a5e2c5456 100644 --- a/src/Classes/RadiusJewelData.lua +++ b/src/Classes/RadiusJewelData.lua @@ -704,49 +704,22 @@ local function previewThreadOfHope(ringName) return previewFromRawText(rawText, displayName) end -local JEWEL_PREVIEW_SCHEMA = { - ["The Light of Meaning"] = { group = true, prefixVariantName = true }, - ["Might of the Meek"] = { }, - ["Unnatural Instinct"] = { }, - ["Inspired Learning"] = { }, - ["Anatomical Knowledge"] = { }, - ["Tempered & Transcendent"] = { group = true }, - ["Lioneye's Fall"] = { }, - ["Intuitive Leap"] = { }, - ["Impossible Escape"] = { group = true, prefixVariantName = true }, - ["Split Personality"] = { group = true, prefixVariantName = true }, - ["Stat Conversion"] = { group = true }, - ["Attribute Conversion"] = { group = true }, - ["Combat Focus"] = { group = true }, - ["Dreams & Nightmares"] = { group = true }, - ["Thread of Hope"] = { thread = true }, -} - -local function buildJewelPreview(name, schema, variant) - if schema.thread then +local function buildJewelPreview(jewelType, variant) + if jewelType.strategy == JEWEL_STRATEGY.THREAD_OF_HOPE then return previewThreadOfHope(variant) elseif variant and variant.rawText then - local displayName = schema.prefixVariantName and (name .. " (" .. variant.name .. ")") or variant.name + 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 schema.group then - return previewFinderGroup(name) - end - return previewUnique(name) -end - -local function makeJewelPreviewFn(name, schema) - return function(variant) - return buildJewelPreview(name, schema, variant) + elseif jewelType.rawText then + return previewUnique(jewelType.name) + elseif jewelType.variants then + return previewFinderGroup(jewelType.name) end + return previewUnique(jewelType.name) end -local jewelPreviewFn = { } -for name, schema in pairs(JEWEL_PREVIEW_SCHEMA) do - jewelPreviewFn[name] = makeJewelPreviewFn(name, schema) -end - -M.jewelPreviewFn = jewelPreviewFn - -- ───────────────────────────────────────────────────────────────────────────── -- Jewel type definitions -- ───────────────────────────────────────────────────────────────────────────── @@ -783,7 +756,6 @@ local function makeJewelType(name, scoreLabel, score, options) jewelType.strategy = jewelType.strategy or JEWEL_STRATEGY.RADIUS jewelType.scoreLabel = scoreLabel jewelType.score = score - jewelType.hasCompute = true if not jewelType.rawText and not jewelType.variants then jewelType.rawText = mustGetUniqueRawText(name) end @@ -792,6 +764,9 @@ local function makeJewelType(name, scoreLabel, score, options) 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 @@ -811,7 +786,6 @@ function M.buildJewelTypes() local intuitiveLeap = makeJewelType("Intuitive Leap", "unalloc passives", scoreUnallocPassives, { strategy = JEWEL_STRATEGY.INTUITIVE_LEAP, - computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, }) appendFoulbornVariants(intuitiveLeap, "Intuitive Leap") @@ -864,6 +838,7 @@ function M.buildJewelTypes() local jewelTypes = { } t_insert(jewelTypes, makeJewelType("The Light of Meaning", "alloc passives", scoreAllocPassives, { + previewOptions = { prefixVariantName = true }, variants = lightOfMeaningVariants, })) t_insert(jewelTypes, mightOfTheMeek) @@ -882,14 +857,12 @@ function M.buildJewelTypes() t_insert(jewelTypes, makeJewelType("Impossible Escape", "unalloc notable/keystone near keystone", scoreUnallocNotablesAndKeystones, { strategy = JEWEL_STRATEGY.IMPOSSIBLE_ESCAPE, - isImpossibleEscape = true, - isEffectSocketIndependent = true, - computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, + previewOptions = { prefixVariantName = true }, variants = M.getImpossibleEscapeVariants(), })) t_insert(jewelTypes, makeJewelType("Split Personality", "dist to start", function() return 0 end, { strategy = JEWEL_STRATEGY.SPLIT_PERSONALITY, - isSplitPersonality = true, + previewOptions = { prefixVariantName = true }, variants = M.getSplitPersonalityVariants(), })) t_insert(jewelTypes, makeJewelType("Stat Conversion", "alloc passives", scoreAllocPassives, { @@ -907,8 +880,6 @@ function M.buildJewelTypes() t_insert(jewelTypes, makeJewelType("Thread of Hope", "unalloc notable/keystone in ring", scoreUnallocNotablesAndKeystones, { strategy = JEWEL_STRATEGY.THREAD_OF_HOPE, - isThread = true, - computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS, rawText = threadOfHopeRawText, })) for _, jewelType in ipairs(jewelTypes) do diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua index de0882c407..547ed90732 100644 --- a/src/Classes/RadiusJewelFinder.lua +++ b/src/Classes/RadiusJewelFinder.lua @@ -18,6 +18,7 @@ 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 = { @@ -47,7 +48,6 @@ 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 jewelPreviewFn = RadiusJewelData.jewelPreviewFn local buildJewelTypes = RadiusJewelData.buildJewelTypes local makeVariantDropdownEntry = RadiusJewelData.makeVariantDropdownEntry local findDisconnectedPassiveComputeMethod = RadiusJewelData.findDisconnectedPassiveComputeMethod @@ -665,13 +665,14 @@ function RadiusJewelResultPresentation:buildPreviewLines(request) if not jewelType then return nil end - local fn = jewelPreviewFn[jewelType.name] + 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 jewelType.isThread then + if strategy.usesThreadVariants then local threadVariant = request.previewVariant or request.selectedThreadVariant return fn(threadVariant and threadVariant.name) elseif jewelType.variants then @@ -702,19 +703,20 @@ function RadiusJewelResultPresentation:buildGenericTypeTooltipLines(request) if not jewelType then return nil end - if not (jewelType.isThread or jewelType.variants) then + 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 = jewelPreviewFn[jewelType.name] + local fn = jewelType.preview local lines = fn and fn() or nil if type(lines) ~= "table" then return nil end - if jewelType.isThread then + if strategy.usesThreadVariants then return lines end @@ -729,13 +731,7 @@ function RadiusJewelResultPresentation:buildGenericTypeTooltipLines(request) end end end - local note - if jewelType.isThread then - note = "Multiple ring sizes available" - else - note = "Multiple variants available" - end - t_insert(genericLines, { height = 16, [1] = COL_META .. note }) + t_insert(genericLines, { height = 16, [1] = COL_META .. "Multiple variants available" }) return genericLines end @@ -1073,11 +1069,20 @@ local JEWEL_STRATEGIES = { 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, @@ -1102,10 +1107,16 @@ local JEWEL_STRATEGIES = { 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(request, variant) - local keystoneNode = variant and request.treeData.keystoneMap[variant.keystoneName] + getDetailNodeId = function(treeData, variant) + local keystoneNode = variant and treeData.keystoneMap[variant.keystoneName] return keystoneNode and keystoneNode.id or nil end, formatFindStatus = function(_, resultCount) @@ -1120,10 +1131,13 @@ local JEWEL_STRATEGIES = { return s_format("^7Split Personality | %d | score/pt", resultCount) end, }, - [JEWEL_STRATEGY.ALL_JEWELS] = { }, + [JEWEL_STRATEGY.ALL_JEWELS] = { + isAllJewels = true, + computeMethods = DISCONNECTED_PASSIVE_COMPUTE_METHODS, + }, } -local function getJewelStrategy(jewelType) +getJewelStrategy = function(jewelType) local strategy = jewelType and JEWEL_STRATEGIES[jewelType.strategy] assert(strategy, "Missing radius jewel strategy: " .. tostring(jewelType and jewelType.name)) return strategy @@ -1152,14 +1166,15 @@ local function runRadiusJewelFind(self, context) local showAllJewelsComputePrompt = context.showAllJewelsComputePrompt local searchStartTime = GetTime() - if selectedJewelType and selectedJewelType.isAllJewels then + 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 = getJewelStrategy(selectedJewelType) + local strategy = selectedStrategy assert(strategy.findSocket, "Radius jewel strategy cannot find: " .. selectedJewelType.name) local findRequest = { jewelType = selectedJewelType, @@ -1224,7 +1239,7 @@ local function runRadiusJewelFind(self, context) 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(findRequest, r.variant) or nil + 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 @@ -1281,7 +1296,7 @@ local function runRadiusJewelCompute(self, context) local selectedImpactStat = context.selectedImpactStat local selectedComputeMethod = context.selectedComputeMethod local selectedJewelType = context.selectedJewelType - local selectedJewelSupportsComputeMethods = context.selectedJewelSupportsComputeMethods + local selectedStrategy = getJewelStrategy(selectedJewelType) local activeJewelTypes = context.activeJewelTypes local jewelSockets = context.jewelSockets local threadVariants = context.threadVariants @@ -1317,7 +1332,7 @@ local function runRadiusJewelCompute(self, context) local ok, err = pcall(function() local statLabel = selectedImpactStat.label local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil) - local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil + local computeMethodLabel = selectedStrategy.computeMethods and computeMethod.label or nil local function makeComputeRequest(variants, computeProgress, skipPlanSteps) return { sockets = jewelSockets, @@ -1383,13 +1398,13 @@ local function runRadiusJewelCompute(self, context) return rows, baseline or 0 end - if selectedJewelType.isAllJewels then + if selectedStrategy.isAllJewels then local allRows = { } local globalBaseline local computeJewelTypes = { } for _, jt in ipairs(activeJewelTypes) do - if not jt.isAllJewels and jt.hasCompute then + if not getJewelStrategy(jt).isAllJewels then t_insert(computeJewelTypes, jt) end end @@ -1460,7 +1475,7 @@ local function runRadiusJewelCompute(self, context) controls.statusLabel.label = formatComputeStatus("All jewels", statLabel, globalBaseline, computeMethodLabel) .. formatElapsed(searchStartTime) else local displayedVariants = getSelectedVariants() - local strategy = getJewelStrategy(selectedJewelType) + local strategy = selectedStrategy local computeRequest = makeComputeRequest(displayedVariants, progress) local itemLabel = strategy.formatComputeLabel and strategy.formatComputeLabel(selectedJewelType, computeRequest) @@ -1587,11 +1602,15 @@ local function buildRadiusJewelPopupContext(self) local suppressFinderStateSave = false local runFind local cancelCompute + local function getSelectedJewelStrategy() + return selectedJewelType and getJewelStrategy(selectedJewelType) or nil + end local function canFindCurrentSelection() - if not selectedJewelType or selectedJewelType.isAllJewels then + local strategy = getSelectedJewelStrategy() + if not strategy or strategy.isAllJewels then return false end - if selectedJewelType.isThread or selectedJewelType.isImpossibleEscape then + if strategy.findsAllVariants then return true end return not selectedJewelType.variants or selectedJewelVariant ~= nil @@ -1624,16 +1643,16 @@ local function buildRadiusJewelPopupContext(self) 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 = selectedJewelType and (selectedJewelType.isAllJewels - or selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0) + local supportsComputeMethods = strategy and strategy.computeMethods and #strategy.computeMethods > 0 local computeMethodKey = supportsComputeMethods and selectedComputeMethod and selectedComputeMethod.id or "" - local legacyKey = selectedJewelType and selectedJewelType.isAllJewels and showLegacy and "1" or "0" - local threadVariantKey = selectedJewelType and selectedJewelType.isThread + 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), @@ -1654,10 +1673,12 @@ local function buildRadiusJewelPopupContext(self) end local function clearResultsForContext() - resultState:clear(selectedJewelType and selectedJewelType.isAllJewels, canFindCurrentSelection()) + local strategy = getSelectedJewelStrategy() + resultState:clear(strategy and strategy.isAllJewels, canFindCurrentSelection()) end local function showCriteriaChangedForContext() - resultState:showCriteriaChanged(selectedJewelType and selectedJewelType.isAllJewels, canFindCurrentSelection()) + local strategy = getSelectedJewelStrategy() + resultState:showCriteriaChanged(strategy and strategy.isAllJewels, canFindCurrentSelection()) end local function isResultContextCurrent(resultContextKey) return resultContextKey == getResultContextKey() @@ -1698,12 +1719,8 @@ local function buildRadiusJewelPopupContext(self) end end local function getSelectedComputeMethods() - if selectedJewelType and selectedJewelType.isAllJewels then - return DISCONNECTED_PASSIVE_COMPUTE_METHODS - end - if selectedJewelType and selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0 then - return selectedJewelType.computeMethods - end + local strategy = getSelectedJewelStrategy() + return strategy and strategy.computeMethods or nil end local function selectedJewelSupportsComputeMethods() local methods = getSelectedComputeMethods() @@ -1895,8 +1912,6 @@ local function buildRadiusJewelPopupContext(self) t_insert(activeJewelTypes, 1, { name = "All jewels", strategy = JEWEL_STRATEGY.ALL_JEWELS, - isAllJewels = true, - hasCompute = true, }) for _, jt in ipairs(activeJewelTypes) do t_insert(jtLabels, jt.name) @@ -1945,7 +1960,8 @@ local function buildRadiusJewelPopupContext(self) local methods = getSelectedComputeMethods() local method = (index and methods and methods[index]) or selectedComputeMethod tooltip:Clear(true) - if selectedJewelType and selectedJewelType.isAllJewels then + 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.") @@ -1961,7 +1977,7 @@ local function buildRadiusJewelPopupContext(self) controls.computeMethodLabel.shown = false controls.computeMethodSelect.shown = false - -- Impact stat selector (shown when jewel has compute) + -- 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() @@ -2112,7 +2128,8 @@ local function buildRadiusJewelPopupContext(self) end local function syncSelectedJewelTypeControls() - if selectedJewelType.isAllJewels then + local strategy = getSelectedJewelStrategy() + if strategy.isAllJewels then controls.allJewelsViewLabel.shown = true controls.allJewelsViewSelect.shown = true controls.threadVariantLabel.shown = false @@ -2125,7 +2142,7 @@ local function buildRadiusJewelPopupContext(self) controls.computeMethodSelect.shown = true controls.impactStatLabel.shown = true controls.impactStatSelect.shown = true - syncComputeMethodSelect(DISCONNECTED_PASSIVE_COMPUTE_METHODS) + syncComputeMethodSelect(strategy.computeMethods) if controls.computeButton then controls.computeButton.shown = true end @@ -2137,27 +2154,27 @@ local function buildRadiusJewelPopupContext(self) end controls.allJewelsViewLabel.shown = false controls.allJewelsViewSelect.shown = false - local isThread = selectedJewelType.isThread == true + local usesThreadVariants = strategy.usesThreadVariants == true local hasVariants = selectedJewelType.variants ~= nil local hasVariantGroupFilter = syncVariantGroupSelect() local hasComputeMethods = selectedJewelSupportsComputeMethods() syncVariantControlLayout(hasVariantGroupFilter) - controls.threadVariantLabel.shown = isThread - controls.threadVariantSelect.shown = isThread + 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 = selectedJewelType.hasCompute - controls.impactStatSelect.shown = selectedJewelType.hasCompute + controls.impactStatLabel.shown = true + controls.impactStatSelect.shown = true if controls.findButton then controls.findButton.shown = true end if controls.computeButton then - controls.computeButton.shown = selectedJewelType.hasCompute + controls.computeButton.shown = true end if hasVariants then @@ -2170,7 +2187,7 @@ local function buildRadiusJewelPopupContext(self) selectedJewelVariant = nil end if hasComputeMethods then - syncComputeMethodSelect(selectedJewelType.computeMethods) + syncComputeMethodSelect(strategy.computeMethods) end end @@ -2184,7 +2201,8 @@ local function buildRadiusJewelPopupContext(self) end) controls.jewelTypeSelect.tooltipFunc = function(tooltip, mode, index) local jewelType = activeJewelTypes[index] - if jewelType and jewelType.isAllJewels then + 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.") @@ -2203,7 +2221,7 @@ local function buildRadiusJewelPopupContext(self) end if index == 1 then addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(selectedJewelType)) - if selectedJewelType.isImpossibleEscape then + 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.") @@ -2282,6 +2300,7 @@ local function buildRadiusJewelPopupContext(self) 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 { } @@ -2306,8 +2325,8 @@ local function buildRadiusJewelPopupContext(self) 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 (jewelType.isThread - and (r.variant.ringLabel or (r.variant.name .. " Ring")) + 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 @@ -2320,7 +2339,7 @@ local function buildRadiusJewelPopupContext(self) or jewelType.limit or (targetRawText and tonumber(targetRawText:match("Limited to: (%d+)"))) or nil - local displayedPlans = (jewelType.name == "Intuitive Leap" or jewelType.isThread or jewelType.isImpossibleEscape) + local displayedPlans = strategy.showsDisconnectedPassivePlans and buildDisplayedDisconnectedPassivePlans(r, points, baseline) or { r } for _, plan in ipairs(displayedPlans) do @@ -2353,11 +2372,7 @@ local function buildRadiusJewelPopupContext(self) end end local detailText = #summaryParts > 0 and t_concat(summaryParts, " | ") or (plan.detailText or "") - local detailNodeId = nil - if jewelType.isImpossibleEscape and r.variant and r.variant.keystoneName then - local keystoneNode = treeData.keystoneMap[r.variant.keystoneName] - detailNodeId = keystoneNode and keystoneNode.id or nil - end + local detailNodeId = strategy.getDetailNodeId and strategy.getDetailNodeId(treeData, r.variant) or nil local actionPlan = self.itemActions:buildPlan({ socketId = r.socket.id, socketLabel = r.socket.label, @@ -2385,12 +2400,10 @@ local function buildRadiusJewelPopupContext(self) jewelName = jewelType.name, jewelLimitKey = jewelLimitKey, jewelLimit = jewelLimit, - isEffectSocketIndependent = jewelType.isEffectSocketIndependent, + isEffectSocketIndependent = strategy.isEffectSocketIndependent, action = actionPlan and actionPlan.kind or nil, actionPlan = actionPlan, - tooltipHeader = jewelType.isThread and "^7Socketing this jewel and allocating the best ring plan here will give you:" - or jewelType.name == "Intuitive Leap" and "^7Socketing this jewel and allocating the best nodes here will give you:" - or jewelType.isImpossibleEscape and "^7Socketing this jewel and allocating the best keystone plan here will give you:" + tooltipHeader = strategy.computeTooltipHeader or variantLabel ~= "" and "^7Socketing the best variant here will give you:" or "^7Socketing this jewel will give you:", }) @@ -2401,7 +2414,7 @@ local function buildRadiusJewelPopupContext(self) controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, headerInputY, 72, buttonHeight }, "Compute", function() local resultContextKey = getResultContextKey() - local selectedThreadVariants = selectedJewelType and selectedJewelType.isThread + local selectedThreadVariants = getSelectedJewelStrategy().usesThreadVariants and getSelectedThreadVariants() or threadVariants runRadiusJewelCompute(self, { controls = controls, @@ -2412,7 +2425,6 @@ local function buildRadiusJewelPopupContext(self) selectedImpactStat = selectedImpactStat, selectedComputeMethod = selectedComputeMethod, selectedJewelType = selectedJewelType, - selectedJewelSupportsComputeMethods = selectedJewelSupportsComputeMethods, activeJewelTypes = activeJewelTypes, jewelSockets = jewelSockets, threadVariants = selectedThreadVariants, @@ -2439,7 +2451,8 @@ local function buildRadiusJewelPopupContext(self) tooltip:AddLine(16, "^8Run Compute again to refresh the results.") return end - if selectedJewelType and selectedJewelType.isAllJewels then + 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.") @@ -2484,14 +2497,16 @@ local function buildRadiusJewelPopupContext(self) cancelCompute() runFind() end) - controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels) + controls.findButton.shown = not (getSelectedJewelStrategy() and getSelectedJewelStrategy().isAllJewels) controls.findButton.enabled = canFindCurrentSelection controls.findButton.tooltipFunc = function(tooltip) tooltip:Clear(true) - if selectedJewelType and selectedJewelType.isThread and not selectedThreadVariant then - tooltip:AddLine(16, "^7Find compares every ring and ranks compatible sockets.") - elseif selectedJewelType and selectedJewelType.isImpossibleEscape and not selectedJewelVariant then - tooltip:AddLine(16, "^7Find compares every displayed Keystone variant and ranks compatible sockets.") + 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.") @@ -2578,7 +2593,8 @@ local function buildRadiusJewelPopupContext(self) end end end - if selectedJewelType and selectedJewelType.isThread and finderState.threadVariantName then + 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 From e032de0227006c1953929b2b6605a637f5e1c12e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 26 Aug 2026 21:03:26 +0200 Subject: [PATCH 51/52] Refresh radius jewel manifest after realignment Record generator-derived hashes for the feature-owned sources after rebasing onto current dev. Exclude unrelated upstream manifest churn. --- manifest.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/manifest.xml b/manifest.xml index fe4baac0c1..7673c2219c 100644 --- a/manifest.xml +++ b/manifest.xml @@ -150,7 +150,7 @@ - + @@ -162,7 +162,7 @@ - + @@ -175,7 +175,7 @@ - + @@ -200,7 +200,7 @@ - + From 846ca4bf0f051990ba02e9e2edbc575021211def Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 26 Aug 2026 23:43:08 +0200 Subject: [PATCH 52/52] Simplify Impossible Escape result fan-out Keep one outer result table per socket while sharing immutable plan and tooltip snapshots inside equivalent groups. Avoid recursively copying passive-node object graphs after the current class proxy caching changes. --- manifest.xml | 2 +- spec/System/TestRadiusJewelCompute_spec.lua | 9 +++++++-- src/Classes/RadiusJewelCompute.lua | 10 ++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/manifest.xml b/manifest.xml index 7673c2219c..e680402d99 100644 --- a/manifest.xml +++ b/manifest.xml @@ -171,7 +171,7 @@ - + diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua index 12fd4108ee..5e0d42f180 100644 --- a/spec/System/TestRadiusJewelCompute_spec.lua +++ b/spec/System/TestRadiusJewelCompute_spec.lua @@ -833,20 +833,22 @@ describe("RadiusJewelCompute #radius-jewel", function() local function countCalculations(cacheKeyFunc) finder.compute.getImpossibleEscapePlanCacheKey = cacheKeyFunc calculationCount = 0 - computeImpossibleEscape(finder.compute, { + local results = computeImpossibleEscape(finder.compute, { sockets = sockets, variants = { variant }, methodId = "fast", maxTotalPoints = 2, skipPlanSteps = true, }) - return calculationCount + return calculationCount, results 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) + sockets[2].pathDist = sockets[1].pathDist + local _, sharedGroupResults = countCalculations(originalCacheKey) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator finder.compute.collectDisconnectedPassiveCandidates = originalCollectCandidates finder.compute.buildSocketReplacementOverride = originalBuildOverride @@ -854,6 +856,9 @@ describe("RadiusJewelCompute #radius-jewel", function() assert.is_true(sharedCount < socketScopedCount, "expected shared cache to avoid repeated Impossible Escape calculations") + assert.are.equal(2, #sharedGroupResults) + assert.are_not.equal(sharedGroupResults[1], sharedGroupResults[2], + "each socket should receive its own result table") end) it("returns results for both methods without changing finder state", function() diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua index e54958cc42..5c28f73c57 100644 --- a/src/Classes/RadiusJewelCompute.lua +++ b/src/Classes/RadiusJewelCompute.lua @@ -1098,6 +1098,12 @@ local function computeImpossibleEscapeRepresentativeResults(self, request) return bestResultByGroupKey end +local function copyResultForSocket(result) + -- Nested plan and tooltip snapshots are immutable after construction; + -- socket fan-out only replaces top-level metadata. + return copyTable(result, true) +end + local function fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGroupKey) local results = { } for _, groupEntry in ipairs(groupedOrder) do @@ -1105,7 +1111,7 @@ local function fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGro if bestResult then for _, socket in ipairs(groupEntry.sockets) do local socketOccupancy = self:getSocketOccupancyInfo(socket.id) - local resultForSocket = copyTableSafe(bestResult, false, true) + local resultForSocket = copyResultForSocket(bestResult) resultForSocket.impossibleEscapeGroupKey = groupEntry.groupKey resultForSocket.socket = socket resultForSocket.replacedItemLabel = socketOccupancy and socketOccupancy.replacedItemLabel or nil @@ -1159,7 +1165,7 @@ local function addImpossibleEscapePlanDetails(self, request) fullResult.impossibleEscapeGroupKey = groupEntry.groupKey for i, result in ipairs(results) do if result.impossibleEscapeGroupKey == groupEntry.groupKey then - local updated = copyTableSafe(fullResult, false, true) + local updated = copyResultForSocket(fullResult) updated.socket = result.socket updated.replacedItemLabel = result.replacedItemLabel updated.storedUnallocatedItemLabel = result.storedUnallocatedItemLabel