From 78b6aeb46a5455933efb557db33752d21736c92d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:24 +0100 Subject: [PATCH 01/37] Add shared Weighted Score ranking Use one set of weights and one ratio calculation for passive-tree and item rankings. Keep weight editing in Trade Query and expose the score through the shared power-stat list. Co-Authored-By: Claude Sonnet 4.6 --- src/Classes/CalcsTab.lua | 7 ++++ src/Classes/ItemDBControl.lua | 33 +++++++++++++++- src/Classes/TradeQuery.lua | 5 ++- src/Classes/TradeQueryGenerator.lua | 36 +---------------- src/Classes/TreeTab.lua | 15 ++++++++ src/Modules/Data.lua | 1 + src/Modules/WeightedScore.lua | 60 +++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 38 deletions(-) create mode 100644 src/Modules/WeightedScore.lua diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 7d79e865d98..9847fae33b5 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -8,6 +8,7 @@ local ipairs = ipairs local t_insert = table.insert local m_max = math.max local m_floor = math.floor +local WeightedScore = LoadModule("Modules/WeightedScore") local buffModeDropList = { { label = "Unbuffed", buffMode = "UNBUFFED" }, @@ -740,6 +741,12 @@ function CalcsTabClass:PowerBuilder() end function CalcsTabClass:CalculatePowerStat(selection, original, modified) + if selection.isWeightedScore then + local weights = WeightedScore.getWeights(self.build) + local nodeScore = WeightedScore.computeRatioScore(modified, original, weights) + local baseScore = WeightedScore.computeRatioScore(modified, modified, weights) + return (nodeScore - baseScore) * 1000 + end local originalValue = data.powerStatList.GetFromOutput(original, selection) local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) return originalValue - modifiedValue diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index bc5c227bf3d..ec9913a57c5 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -8,7 +8,7 @@ local ipairs = ipairs local t_insert = table.insert local m_max = math.max local m_floor = math.floor - +local WeightedScore = LoadModule("Modules/WeightedScore") ---@class ItemDBControl: ListControl local ItemDBClass = newClass("ItemDBControl", "ListControl") @@ -54,6 +54,14 @@ function ItemDBClass:ItemDBControl(anchor, rect, itemsTab, db, dbType) self.controls.league = new("DropDownControl"):DropDownControl({"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, self.leagueList, function(index, value) self.listBuildFlag = true end) + self.controls.editWeights = new("ButtonControl"):ButtonControl({"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, "Edit Weights...", function() + local tq = self.itemsTab.tradeQuery + if tq then + tq:SetStatWeights(nil, function() self.listBuildFlag = true end) + end + end) + self.controls.league.shown = function() return self.sortMode ~= "WeightedScore" end + self.controls.editWeights.shown = function() return self.sortMode == "WeightedScore" end self.controls.requirement = new("DropDownControl"):DropDownControl({"LEFT",self.controls.sort,"BOTTOMLEFT"}, {0, 11, 179, 18}, { "Any requirements", "Current level", "Current attributes", "Current useable" }, function(index, value) self.listBuildFlag = true end) @@ -228,6 +236,7 @@ function ItemDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -252,7 +261,27 @@ function ItemDBClass:ListBuilder() end end - if self.sortDetail and self.sortDetail.stat then -- stat-based + if self.sortDetail and self.sortDetail.isWeightedScore then + local start = GetTime() + local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) + local weights = WeightedScore.getWeights(self.itemsTab.build) + for itemIndex, item in ipairs(list) do + item.measuredPower = 0 + for slotName, slot in pairs(self.itemsTab.slots) do + if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then + local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }) + local score = WeightedScore.computeRatioScore(calcBase, output, weights) + item.measuredPower = m_max(item.measuredPower, score) + end + end + local now = GetTime() + if now - start > 50 then + self.defaultText = "^7Sorting... ("..m_floor(itemIndex/#list*100).."%)" + coroutine.yield() + start = now + end + end + elseif self.sortDetail and self.sortDetail.stat then -- stat-based local useFullDPS = self.sortDetail.stat == "FullDPS" local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 66ad1fc1ff9..0bdcfef91fa 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -688,7 +688,7 @@ Highest Weight - Displays the order retrieved from trade]] end -- Popup to set stat weight multipliers for sorting -function TradeQueryClass:SetStatWeights(previousSelectionList) +function TradeQueryClass:SetStatWeights(previousSelectionList, onSave) previousSelectionList = previousSelectionList or {} local controls = { } local statList = { } @@ -703,7 +703,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) { -410, 45, 400, listHeight }, statList, sliderController) for _, stat in ipairs(data.powerStatList) do - if not stat.ignoreForItems and stat.label ~= "Name" then + if not stat.ignoreForItems and stat.label ~= "Name" and not stat.isWeightedScore then t_insert(statList, { label = "0 : "..stat.label, stat = { @@ -771,6 +771,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) for row_idx in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end + if onSave then onSave() end end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() if previousSelectionList and #previousSelectionList > 0 then diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 826cb34a086..62762d6fb59 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -5,6 +5,7 @@ -- local dkjson = require "dkjson" +local WeightedScore = LoadModule("Modules/WeightedScore") local curl = require("lcurl.safe") local m_max = math.max local s_format = string.format @@ -162,40 +163,7 @@ function TradeQueryGeneratorClass:TradeQueryGenerator(queryTab) end function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, statWeights) - local meanStatDiff = 0 - - local function ratioModSums(...) - local baseModSum = 0 - local newModSum = 0 - for _, mod in ipairs({ ... }) do - baseModSum = baseModSum + data.powerStatList.GetFromOutput(baseOutput, mod, true) - newModSum = newModSum + data.powerStatList.GetFromOutput(newOutput, mod, true) - end - - if baseModSum == math.huge then - return 0 - else - if newModSum == math.huge then - return data.misc.maxStatIncrease - else - return math.min(newModSum / ((baseModSum ~= 0) and baseModSum or 1), data.misc.maxStatIncrease) - end - end - end - for _, statTable in ipairs(statWeights) do - local modSumRatio - if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then - modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) - else - modSumRatio = ratioModSums(statTable) - end - -- some weights, such as damage taken from hit need to be negated as lower is better for them - if statTable.transform then - modSumRatio = statTable.transform(modSumRatio) - end - meanStatDiff = meanStatDiff + modSumRatio * statTable.weightMult - end - return meanStatDiff + return WeightedScore.computeRatioScore(baseOutput, newOutput, statWeights) end function TradeQueryGeneratorClass:ProcessMod(modId, mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 842b3b4e09a..bc597ec8816 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -15,6 +15,7 @@ local m_min = math.min local m_floor = math.floor local m_abs = math.abs local s_format = string.format +local WeightedScore = LoadModule("Modules/WeightedScore") local s_gsub = string.gsub local s_byte = string.byte local dkjson = require "dkjson" @@ -274,6 +275,18 @@ function TreeTabClass:TreeTab(build) self.controls.powerReportList.shown = not self.controls.powerReportList.shown end) + -- Edit Weights button (only shown when Weighted Score heatmap mode is active) + self.controls.editWeights = new("ButtonControl"):ButtonControl( + { "LEFT", self.controls.powerReport, "RIGHT" }, { 8, 0, 130, 20 }, + "Edit Weights...", + function() + local tq = self.build.itemsTab.tradeQuery + if tq then + tq:SetStatWeights(nil, function() self:SetPowerCalc(self.build.calcsTab.powerStat) end) + end + end) + self.controls.editWeights.shown = false + -- Power Report List local yPos = self.controls.treeHeatMap.y == 0 and self.controls.specSelect.height + 4 or self.controls.specSelect.height * 2 + 8 self.controls.powerReportList = new("PowerReportListControl"):PowerReportListControl({ "TOPLEFT", self.controls.specSelect, "BOTTOMLEFT" }, { 0, yPos, 700, 170 }, function(selectedNode) @@ -464,6 +477,7 @@ function TreeTabClass:Draw(viewPort, inputEvents) self.controls.treeHeatMap.state = self.viewer.showHeatMap self.controls.treeHeatMapStatSelect.shown = self.viewer.showHeatMap + self.controls.editWeights.shown = self.viewer.showHeatMap and self.build.calcsTab.powerStat and self.build.calcsTab.powerStat.isWeightedScore or false self.controls.treeHeatMapStatSelect.list = self.powerStatList self.controls.treeHeatMapStatSelect.selIndex = 1 self.controls.treeHeatMapStatSelect:CheckDroppedWidth(true) @@ -1049,6 +1063,7 @@ function TreeTabClass:SetPowerCalc(powerStat) self.build.buildFlag = true self.build.calcsTab.powerBuildFlag = true self.build.calcsTab.powerStat = powerStat + self.controls.editWeights.shown = powerStat and powerStat.isWeightedScore or false self.controls.powerReportList:SetReport(powerStat, nil) -- Remove old toast and clear dismissed state so toast can show for new power report if self.powerBuilderToastId then diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 26afb5f4dfb..0e093973d4a 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -177,6 +177,7 @@ data.powerStatList = { { stat="BlockChance", label="Block Chance" }, { stat="SpellBlockChance", label="Spell Block Chance" }, { stat="SpellSuppressionChance", label="Spell Suppression Chance" }, + { stat="WeightedScore", label="Weighted Score", isWeightedScore=true }, } ---@param output any Calc output diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua new file mode 100644 index 00000000000..831c1ba9436 --- /dev/null +++ b/src/Modules/WeightedScore.lua @@ -0,0 +1,60 @@ +-- Path of Building +-- +-- Module: Weighted Score +-- Shared weighted stat score computation and weight management. +-- Used by Trade Query, Unique Item DB, Gem Upgrade Report, and Tree heatmap. +-- + +local WeightedScore = {} + +-- Default stat weight configuration used when no custom weights are saved. +function WeightedScore.defaultWeights() + return { + { stat = "FullDPS", label = "Full DPS", weightMult = 1.0 }, + { stat = "TotalEHP", label = "Effective Hit Pool", weightMult = 0.5 }, + } +end + +-- Returns the current stat weight list from the build's trade query settings, +-- falling back to defaults if none are configured or the build is not available. +function WeightedScore.getWeights(build) + local tq = build and build.itemsTab and build.itemsTab.tradeQuery + if tq and tq.statSortSelectionList and #tq.statSortSelectionList > 0 then + return tq.statSortSelectionList + end + return WeightedScore.defaultWeights() +end + +-- Compute a weighted ratio score comparing newOutput to baseOutput. +-- Each stat contributes: weight * (newOutput[stat] / baseOutput[stat]). +-- A neutral candidate (same as base) scores approximately sum(weights). +-- Higher score means the candidate is better. +-- Missing or zero stats are handled safely (no crash, no infinite values). +function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) + local meanStatDiff = 0.0 + local function ratioModSums(...) + local baseModSum = 0 + local newModSum = 0 + for _, mod in ipairs({ ... }) do + baseModSum = baseModSum + (baseOutput[mod] or 0) + newModSum = newModSum + (newOutput[mod] or 0) + end + if baseModSum == math.huge then + return 0 + elseif newModSum == math.huge then + return data.misc.maxStatIncrease + else + return math.min(newModSum / ((baseModSum ~= 0) and baseModSum or 1), data.misc.maxStatIncrease) + end + end + for _, statTable in ipairs(weights) do + if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then + -- FullDPS fallback: use combined DPS components when FullDPS is not directly available + meanStatDiff = meanStatDiff + (ratioModSums("TotalDPS", "TotalDotDPS", "CombinedDPS") or 0) * statTable.weightMult + end + meanStatDiff = meanStatDiff + (ratioModSums(statTable.stat) or 0) * statTable.weightMult + end + return meanStatDiff +end + +return WeightedScore From 7323da468f713de32054b8f25553499de6a36a26 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:32 +0100 Subject: [PATCH 02/37] Test shared Weighted Score behavior Cover default and custom weights, ratio edge cases, Trade Query scoring, and passive-tree integration. Co-Authored-By: Claude Sonnet 4.6 --- spec/System/TestWeightedScore_spec.lua | 223 +++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 spec/System/TestWeightedScore_spec.lua diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua new file mode 100644 index 00000000000..3d67596c857 --- /dev/null +++ b/spec/System/TestWeightedScore_spec.lua @@ -0,0 +1,223 @@ +local WeightedScore = LoadModule("Modules/WeightedScore") + +describe("WeightedScore module", function() + -- Save and restore maxStatIncrease around the whole suite so we don't + -- pollute other spec files that rely on the real game value. + local savedMaxStatIncrease + before_each(function() + savedMaxStatIncrease = data.misc.maxStatIncrease + data.misc.maxStatIncrease = 2 + end) + after_each(function() + data.misc.maxStatIncrease = savedMaxStatIncrease + end) + + -- defaultWeights ----------------------------------------------------------- + + it("defaultWeights returns two entries (FullDPS and TotalEHP)", function() + local weights = WeightedScore.defaultWeights() + assert.are.equal(2, #weights) + assert.are.equal("FullDPS", weights[1].stat) + assert.are.equal("TotalEHP", weights[2].stat) + end) + + -- getWeights --------------------------------------------------------------- + + it("getWeights returns defaults when build is nil", function() + local weights = WeightedScore.getWeights(nil) + assert.are.same(WeightedScore.defaultWeights(), weights) + end) + + it("getWeights returns defaults when statSortSelectionList is empty", function() + local mockBuild = { + itemsTab = { + tradeQuery = { statSortSelectionList = {} } + } + } + local weights = WeightedScore.getWeights(mockBuild) + assert.are.same(WeightedScore.defaultWeights(), weights) + end) + + it("getWeights returns custom weights when statSortSelectionList is populated", function() + local custom = { { stat = "TotalDPS", label = "DPS", weightMult = 2.0 } } + local mockBuild = { + itemsTab = { + tradeQuery = { statSortSelectionList = custom } + } + } + local weights = WeightedScore.getWeights(mockBuild) + assert.are.equal(1, #weights) + assert.are.equal("TotalDPS", weights[1].stat) + assert.are.equal(2.0, weights[1].weightMult) + end) + + -- computeRatioScore: basic ranking ----------------------------------------- + + it("neutral candidate (identical outputs) scores 1.0 with single unit weight", function() + local base = { TotalDPS = 1000 } + local new = { TotalDPS = 1000 } + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + assert.are.equal(1.0, WeightedScore.computeRatioScore(base, new, weights)) + end) + + it("better candidate scores higher than neutral", function() + local base = { TotalDPS = 1000 } + local better = { TotalDPS = 1500 } + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + local score = WeightedScore.computeRatioScore(base, better, weights) + assert.is_true(score > 1.0) + assert.are.equal(1.5, score) + end) + + it("worse candidate scores lower than neutral", function() + local base = { TotalDPS = 1000 } + local worse = { TotalDPS = 500 } + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + local score = WeightedScore.computeRatioScore(base, worse, weights) + assert.is_true(score < 1.0) + assert.are.equal(0.5, score) + end) + + it("empty weights always scores 0", function() + local base = { TotalDPS = 1000 } + local new = { TotalDPS = 5000 } + assert.are.equal(0.0, WeightedScore.computeRatioScore(base, new, {})) + end) + + -- computeRatioScore: edge cases -------------------------------------------- + + it("infinite base stat contributes 0 (no crash)", function() + local base = { TotalDPS = math.huge } + local new = { TotalDPS = 1000 } + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + assert.are.equal(0.0, WeightedScore.computeRatioScore(base, new, weights)) + end) + + it("infinite new stat is capped at maxStatIncrease", function() + local base = { TotalDPS = 1000 } + local new = { TotalDPS = math.huge } + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + -- maxStatIncrease == 2 (set in before_each) + assert.are.equal(2.0, WeightedScore.computeRatioScore(base, new, weights)) + end) + + it("zero base stat treats denominator as 1 and caps at maxStatIncrease (no div-by-zero crash)", function() + local base = { TotalDPS = 0 } + local new = { TotalDPS = 500 } -- 500/1 = 500, capped at 2 + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + assert.are.equal(2.0, WeightedScore.computeRatioScore(base, new, weights)) + end) + + it("missing stat in both base and new scores 0 (no crash)", function() + local base = {} + local new = {} + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + -- 0/1 = 0 + assert.are.equal(0.0, WeightedScore.computeRatioScore(base, new, weights)) + end) + + -- computeRatioScore: FullDPS fallback -------------------------------------- + + it("uses combined DPS fallback when FullDPS is absent from both outputs", function() + -- baseSum = 500+200+300 = 1000, newSum = 750+300+450 = 1500 → ratio 1.5 + local base = { TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 } + local new = { TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 } + local weights = { { stat = "FullDPS", weightMult = 1.0 } } + assert.are.equal(1.5, WeightedScore.computeRatioScore(base, new, weights)) + end) + + it("does not activate fallback when FullDPS is present (no double-counting)", function() + -- If fallback also ran, score would be higher than 1.5 (the FullDPS ratio) + local base = { FullDPS = 1000, TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 } + local new = { FullDPS = 1500, TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 } + local weights = { { stat = "FullDPS", weightMult = 1.0 } } + -- Only FullDPS direct: 1500/1000 = 1.5 + assert.are.equal(1.5, WeightedScore.computeRatioScore(base, new, weights)) + end) +end) + +describe("WeightedScore — TradeQueryGenerator delegation", function() + local mock_queryGen = new("TradeQueryGenerator", { + itemsTab = {}, + GetTradeStatusOption = function() return "online" end, + }) + + -- Pass: WeightedRatioOutputs returns the same value as calling + -- WeightedScore.computeRatioScore directly, confirming delegation + -- Fail: divergence would indicate the wrapper has extra logic or a copy-paste + it("WeightedRatioOutputs delegates to WeightedScore.computeRatioScore", function() + local savedMax = data.misc.maxStatIncrease + data.misc.maxStatIncrease = 2 + + local base = { TotalDPS = 1000, TotalEHP = 500 } + local new = { TotalDPS = 1200, TotalEHP = 600 } + local weights = { { stat = "TotalDPS", weightMult = 1.0 }, { stat = "TotalEHP", weightMult = 0.5 } } + + local direct = WeightedScore.computeRatioScore(base, new, weights) + local delegated = mock_queryGen.WeightedRatioOutputs(base, new, weights) + + data.misc.maxStatIncrease = savedMax + assert.are.equal(direct, delegated) + end) + + -- Pass: higher-stat candidate ranks above lower-stat candidate + -- Fail: regression in delegation would silently return 0 for all, making order random + it("higher-stat candidate ranks above lower-stat candidate", function() + local base = { TotalDPS = 1000 } + local high = { TotalDPS = 1500 } + local low = { TotalDPS = 800 } + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + + local highScore = mock_queryGen.WeightedRatioOutputs(base, high, weights) + local lowScore = mock_queryGen.WeightedRatioOutputs(base, low, weights) + assert.is_true(highScore > lowScore) + end) +end) + +describe("WeightedScore — tree integration", function() + before_each(function() + newBuild() + end) + + local function findStat(statName) + for _, stat in ipairs(data.powerStatList) do + if stat.stat == statName then return stat end + end + end + + local function drainPowerBuild(stat) + build.calcsTab.powerBuildFlag = true + build.calcsTab.powerStat = stat or findStat("Life") + local maxIter = 100000 + local iter = 0 + repeat + build.calcsTab:BuildPower() + iter = iter + 1 + until not build.calcsTab.powerBuilder or iter >= maxIter + end + + -- Pass: WeightedScore entry is registered in the shared power stat list + -- Fail: missing registration would mean the mode never appears in the UI + it("WeightedScore entry exists in data.powerStatList with isWeightedScore flag", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + assert.is_true(stat.isWeightedScore) + end) + + -- Pass: power builder runs to completion without Lua error + -- Fail: a crash in CalculatePowerStat's isWeightedScore branch + it("power builder completes without error using WeightedScore stat", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + drainPowerBuild(stat) + assert.is_true(build.calcsTab.powerBuilderInitialized) + end) + + -- Pass: powerMax is initialized and singleStat is non-negative + -- Fail: negative singleStat would break heatmap colour scaling + it("powerMax.singleStat is non-negative after WeightedScore build", function() + drainPowerBuild(findStat("WeightedScore")) + assert.is_not_nil(build.calcsTab.powerMax) + assert.is_true(build.calcsTab.powerMax.singleStat >= 0) + end) +end) From 0809657a2bc6f3a4b4f820f26d4a7e3635170c05 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 22 Mar 2026 19:00:45 +0100 Subject: [PATCH 03/37] Add Weighted Score sorting to Anoint Propagate Weighted Score through the Anoint sort list and calculate each notable instead of leaving every score at zero. Co-Authored-By: Claude Sonnet 4.6 --- src/Classes/NotableDBControl.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index 8253f4a471f..a03ccd3599e 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -11,6 +11,7 @@ local m_max = math.max local m_floor = math.floor local m_huge = math.huge local s_format = string.format +local WeightedScore = LoadModule("Modules/WeightedScore") ---@param node table ---@return boolean @@ -99,6 +100,7 @@ function NotableDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -135,12 +137,17 @@ function NotableDBClass:ListBuilder() local calcFunc = self.itemsTab.build.calcsTab:GetMiscCalculator() local itemType = self.itemsTab.displayItem.base.type local calcBase = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(nil) }) + local weights = self.sortDetail.isWeightedScore and WeightedScore.getWeights(self.itemsTab.build) self.sortMaxPower = 0 for nodeIndex, node in ipairs(list) do node.measuredPower = 0 if node.modKey ~= "" then local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }) - node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) + if self.sortDetail.isWeightedScore then + node.measuredPower = WeightedScore.computeRatioScore(calcBase, output, weights) + else + node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) + end if node.measuredPower == m_huge then t_insert(infinites, node) else From 4477fc5162b6c45204655350e7d109917b406ab5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 00:00:00 +0100 Subject: [PATCH 04/37] Expose Weighted Score through powerStatList Weighted Score is calculated rather than stored in a calc output field. Expose it through the shared power-stat callback so other consumers can request the score without duplicating the calculation. Co-Authored-By: Claude Sonnet 4.6 --- src/Modules/Data.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 0e093973d4a..43ad0d61954 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -177,7 +177,12 @@ data.powerStatList = { { stat="BlockChance", label="Block Chance" }, { stat="SpellBlockChance", label="Spell Block Chance" }, { stat="SpellSuppressionChance", label="Spell Suppression Chance" }, - { stat="WeightedScore", label="Weighted Score", isWeightedScore=true }, + { stat="WeightedScore", label="Weighted Score", isWeightedScore=true, getValue=function(output, build) + local WeightedScore = LoadModule("Modules/WeightedScore") + local weights = WeightedScore.getWeights(build) + local _, buildBase = build.calcsTab:GetMiscCalculator() + return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 + end }, } ---@param output any Calc output From bebef3b560ced259a99bafb91e08e7fbc4f3782f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 23:11:33 +0100 Subject: [PATCH 05/37] Load WeightedScore once in Data Loading WeightedScore inside getValue created a new module table on every call. Load it once at module scope for tight loops such as Radius Jewel Finder calculations. Co-Authored-By: Claude Opus 4.6 --- src/Modules/Data.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 43ad0d61954..1726955be9e 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -6,6 +6,7 @@ local dkjson = require("dkjson") LoadModule("Data/Global") +local WeightedScore = LoadModule("Modules/WeightedScore") local m_min = math.min local m_max = math.max local m_floor = math.floor @@ -178,7 +179,6 @@ data.powerStatList = { { stat="SpellBlockChance", label="Spell Block Chance" }, { stat="SpellSuppressionChance", label="Spell Suppression Chance" }, { stat="WeightedScore", label="Weighted Score", isWeightedScore=true, getValue=function(output, build) - local WeightedScore = LoadModule("Modules/WeightedScore") local weights = WeightedScore.getWeights(build) local _, buildBase = build.calcsTab:GetMiscCalculator() return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 From bc8c53bd60022babbb2b21bf00c8887cf9553b0b Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:18:29 +0100 Subject: [PATCH 06/37] Use Weighted Score for Timeless Jewel fallback weights Timeless Jewel fallback weights previously read a nonexistent output field and collapsed every node to the same result. Use the Weighted Score callback and guard a zero baseline. Co-Authored-By: Claude Sonnet 4.6 --- spec/System/TestWeightedScore_spec.lua | 30 ++++++++++++++++++++++++++ src/Classes/TreeTab.lua | 10 +++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 3d67596c857..da24c2b20de 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -220,4 +220,34 @@ describe("WeightedScore — tree integration", function() assert.is_not_nil(build.calcsTab.powerMax) assert.is_true(build.calcsTab.powerMax.singleStat >= 0) end) + + -- Pass: getValue returns a positive score when the new output is better than base + -- Fail: reading output["WeightedScore"] (non-existent field) would return 0, giving + -- weight1 = (0/1 - 1)*100 = -100 for every fallback node regardless of actual impact + it("getValue on WeightedScore entry returns positive score for better output", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + assert.is_function(stat.getValue) + local calcFunc = build.calcsTab:GetMiscCalculator(build) + local baseOutput = calcFunc() + -- Synthesize a "better" output by doubling FullDPS relative to base + local betterOutput = setmetatable({}, { __index = baseOutput }) + betterOutput.FullDPS = (baseOutput.FullDPS or 0) * 2 + 1 + local baseScore = stat.getValue(baseOutput, build) + local betterScore = stat.getValue(betterOutput, build) + assert.is_true(betterScore > baseScore) + end) + + -- Pass: getValue returns a non-zero base score (build has some meaningful output) + -- Fail: if getValue silently returned 0 for base, generateFallbackWeights would + -- set baseValue=1 and all weights would be computed against 1 instead of the + -- real build score, producing incorrect -100 values for all neutral nodes + it("getValue on WeightedScore entry returns non-zero score for current build output", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + local calcFunc = build.calcsTab:GetMiscCalculator(build) + local baseOutput = calcFunc() + local score = stat.getValue(baseOutput, build) + assert.is_true(score ~= 0) + end) end) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index bc597ec8816..aad4f23ff42 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1967,7 +1967,13 @@ function TreeTabClass:FindTimelessJewel() local function generateFallbackWeights(nodes, powerStat) local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) local newList = { } - local basePower = data.powerStatList.GetFromOutput(calcBase, powerStat) + local function getStatValue(output) + if powerStat.getValue then + return powerStat.getValue(output, self.build) + end + return data.powerStatList.GetFromOutput(output, powerStat) + end + local basePower = getStatValue(calcBase) for _, newNode in ipairs(nodes) do local powerEntry = { id = newNode.id } -- nodes that have multiple lines are represented as a list in newNode.node @@ -1975,7 +1981,7 @@ function TreeTabClass:FindTimelessJewel() for i = 1, #nodeLines do local node = nodeLines[i] local nodeOutput = calcFunc({ addNodes = { [node] = true } }) - local nodePower = data.powerStatList.GetFromOutput(nodeOutput, powerStat) + local nodePower = getStatValue(nodeOutput) -- avoid infinity if basePower == 0 then powerEntry["weight" .. i] = 0 From 70e3d8b4ee4dc9c42e91df7833e42e55ecd6c573 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:38:23 +0100 Subject: [PATCH 07/37] Keep getValue in fallback weight entries Fallback dropdown entries copied only the stat, transform, and label, dropping the callback needed by Weighted Score. Carry getValue into each entry. Co-Authored-By: Claude Sonnet 4.6 --- spec/System/TestWeightedScore_spec.lua | 21 +++++++++++++++++++++ src/Classes/TreeTab.lua | 1 + 2 files changed, 22 insertions(+) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index da24c2b20de..8fd2c1c59e7 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -250,4 +250,25 @@ describe("WeightedScore — tree integration", function() local score = stat.getValue(baseOutput, build) assert.is_true(score ~= 0) end) + + -- Pass: fallbackWeightsList entries for WeightedScore carry getValue + -- Fail: if getValue is not copied into the dropdown entry, generateFallbackWeights + -- receives selection.getValue = nil and falls back to output["WeightedScore"] + -- which is always nil, producing weight = -100 for every node + it("WeightedScore fallbackWeightsList entry carries getValue callback", function() + local found = nil + for _, entry in pairs(data.powerStatList) do + if entry.stat == "WeightedScore" and not entry.ignoreForItems and entry.label ~= "Name" then + found = { + label = "Sort by " .. entry.label, + stat = entry.stat, + transform = entry.transform, + getValue = entry.getValue, + } + break + end + end + assert.is_not_nil(found, "WeightedScore entry should appear in fallbackWeightsList candidates") + assert.is_function(found.getValue, "getValue must be propagated into the dropdown entry") + end) end) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index aad4f23ff42..8203629be80 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -2145,6 +2145,7 @@ function TreeTabClass:FindTimelessJewel() label = "Sort by " .. stat.label, stat = stat.stat, transform = stat.transform, + getValue = stat.getValue, }) end end From 13dc62566db65abff0a139f54abc437b4d0536e0 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 28 Mar 2026 11:33:00 +0100 Subject: [PATCH 08/37] Initialize stat weights before opening the editor --- src/Classes/TradeQuery.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 0bdcfef91fa..d90ab0e5efc 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -690,6 +690,10 @@ end -- Popup to set stat weight multipliers for sorting function TradeQueryClass:SetStatWeights(previousSelectionList, onSave) previousSelectionList = previousSelectionList or {} + if not self.statSortSelectionList or (#self.statSortSelectionList) == 0 then + self.statSortSelectionList = { } + initStatSortSelectionList(self.statSortSelectionList) + end local controls = { } local statList = { } local sliderController = { index = 1 } From 664226c1d5918854a6833cf1f24fe6e7718f2d37 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 9 May 2026 09:45:19 +0200 Subject: [PATCH 09/37] Show Weighted Score in Power Report Use Full DPS when the active weights require it and reuse the cached baseline for Weighted Score calculations. --- spec/System/TestWeightedScore_spec.lua | 108 +++++++++++++++++++++++++ src/Classes/CalcsTab.lua | 6 +- src/Modules/Data.lua | 8 +- src/Modules/WeightedScore.lua | 14 ++++ 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 8fd2c1c59e7..58ef0d621e1 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -134,6 +134,48 @@ describe("WeightedScore module", function() -- Only FullDPS direct: 1500/1000 = 1.5 assert.are.equal(1.5, WeightedScore.computeRatioScore(base, new, weights)) end) + + -- weightsNeedFullDPS: routing helper used by PowerBuilder ------------------ + + it("weightsNeedFullDPS returns false for nil weights", function() + assert.is_false(WeightedScore.weightsNeedFullDPS(nil)) + end) + + it("weightsNeedFullDPS returns false for empty weights", function() + assert.is_false(WeightedScore.weightsNeedFullDPS({})) + end) + + it("weightsNeedFullDPS returns true when FullDPS is the only weight", function() + local weights = { { stat = "FullDPS", weightMult = 1.0 } } + assert.is_true(WeightedScore.weightsNeedFullDPS(weights)) + end) + + it("weightsNeedFullDPS returns false when only non-FullDPS weights are present", function() + local weights = { + { stat = "TotalEHP", weightMult = 0.5 }, + { stat = "TotalDPS", weightMult = 1.0 }, + } + assert.is_false(WeightedScore.weightsNeedFullDPS(weights)) + end) + + it("weightsNeedFullDPS returns true when FullDPS appears alongside other weights", function() + local weights = { + { stat = "TotalEHP", weightMult = 0.5 }, + { stat = "FullDPS", weightMult = 1.0 }, + { stat = "Life", weightMult = 0.25 }, + } + assert.is_true(WeightedScore.weightsNeedFullDPS(weights)) + end) + + it("weightsNeedFullDPS returns false when FullDPS weight is zero", function() + local weights = { { stat = "FullDPS", weightMult = 0 } } + assert.is_false(WeightedScore.weightsNeedFullDPS(weights)) + end) + + it("weightsNeedFullDPS returns false for custom-stat-only weights", function() + local weights = { { stat = "TotalAttr", weightMult = 1.0 } } + assert.is_false(WeightedScore.weightsNeedFullDPS(weights)) + end) end) describe("WeightedScore — TradeQueryGenerator delegation", function() @@ -221,6 +263,47 @@ describe("WeightedScore — tree integration", function() assert.is_true(build.calcsTab.powerMax.singleStat >= 0) end) + it("power report requests FullDPS for WeightedScore when active weights use FullDPS", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + + local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator + local originalNodePowerMaxDepth = build.calcsTab.nodePowerMaxDepth + local calledUseFullDPS = { } + build.calcsTab.nodePowerMaxDepth = 1 + build.calcsTab.GetMiscCalculator = function() + local function calcFunc(_, useFullDPS) + calledUseFullDPS[#calledUseFullDPS + 1] = useFullDPS + return { + FullDPS = 110, + TotalEHP = 100, + CombinedDPS = 0, + TotalDPS = 0, + TotalDotDPS = 0, + } + end + return calcFunc, { + FullDPS = 100, + TotalEHP = 100, + CombinedDPS = 0, + TotalDPS = 0, + TotalDotDPS = 0, + } + end + + local ok, errMsg = pcall(function() + drainPowerBuild(stat) + end) + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + build.calcsTab.nodePowerMaxDepth = originalNodePowerMaxDepth + + assert.is_true(ok, errMsg) + assert.is_true(#calledUseFullDPS > 0, "fixture should exercise candidate calculations") + for _, useFullDPS in ipairs(calledUseFullDPS) do + assert.is_true(useFullDPS) + end + end) + -- Pass: getValue returns a positive score when the new output is better than base -- Fail: reading output["WeightedScore"] (non-existent field) would return 0, giving -- weight1 = (0/1 - 1)*100 = -100 for every fallback node regardless of actual impact @@ -238,6 +321,31 @@ describe("WeightedScore — tree integration", function() assert.is_true(betterScore > baseScore) end) + it("getValue on WeightedScore entry reuses provided calcBase", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + assert.is_function(stat.getValue) + + local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator + local getMiscCalculatorCalls = 0 + build.calcsTab.GetMiscCalculator = function() + getMiscCalculatorCalls = getMiscCalculatorCalls + 1 + return function() + return { FullDPS = 1, TotalEHP = 1 } + end, { FullDPS = 1, TotalEHP = 1 } + end + + local score = stat.getValue( + { FullDPS = 120, TotalEHP = 100, TotalDPS = 0, TotalDotDPS = 0, CombinedDPS = 0 }, + build, + { FullDPS = 100, TotalEHP = 100, TotalDPS = 0, TotalDotDPS = 0, CombinedDPS = 0 } + ) + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + + assert.are.equal(0, getMiscCalculatorCalls) + assert.is_true(score > 0) + end) + -- Pass: getValue returns a non-zero base score (build has some meaningful output) -- Fail: if getValue silently returned 0 for base, generateFallbackWeights would -- set baseValue=1 and all weights would be computed against 1 instead of the diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 9847fae33b5..5afbb52d05e 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -496,7 +496,11 @@ end -- Estimate the offensive and defensive power of all unallocated nodes function CalcsTabClass:PowerBuilder() -- local timer_start = GetTime() - local useFullDPS = self.powerStat and self.powerStat.stat == "FullDPS" + local useFullDPS = self.powerStat and ( + self.powerStat.stat == "FullDPS" + or (self.powerStat.isWeightedScore + and WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(self.build))) + ) local calcFunc, calcBase = self:GetMiscCalculator() local cache = { } local distanceMap = { } diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 1726955be9e..e157fa32f4a 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -178,9 +178,13 @@ data.powerStatList = { { stat="BlockChance", label="Block Chance" }, { stat="SpellBlockChance", label="Spell Block Chance" }, { stat="SpellSuppressionChance", label="Spell Suppression Chance" }, - { stat="WeightedScore", label="Weighted Score", isWeightedScore=true, getValue=function(output, build) + { stat="WeightedScore", label="Weighted Score", isWeightedScore=true, getValue=function(output, build, calcBase) local weights = WeightedScore.getWeights(build) - local _, buildBase = build.calcsTab:GetMiscCalculator() + local buildBase = calcBase + if not buildBase then + local _, cachedBuildBase = build.calcsTab:GetMiscCalculator() + buildBase = cachedBuildBase + end return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 end }, } diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index 831c1ba9436..752c67fc0d5 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -25,6 +25,20 @@ function WeightedScore.getWeights(build) return WeightedScore.defaultWeights() end +-- Returns true when any active weight targets FullDPS, so callers can route +-- through the FullDPS-aware calculation path. +function WeightedScore.weightsNeedFullDPS(weights) + if not weights then + return false + end + for _, statTable in ipairs(weights) do + if statTable and statTable.stat == "FullDPS" and (statTable.weightMult == nil or statTable.weightMult ~= 0) then + return true + end + end + return false +end + -- Compute a weighted ratio score comparing newOutput to baseOutput. -- Each stat contributes: weight * (newOutput[stat] / baseOutput[stat]). -- A neutral candidate (same as base) scores approximately sum(weights). From 9b13712ea4719719f05494077da061583477351d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 10 May 2026 08:23:01 +0200 Subject: [PATCH 10/37] Put Edit Weights next to Weighted Score Use one dropdown action across Tree, Item DB, and Anoint selectors. Keeping the action inside the menu leaves the League filter available in the narrow Item DB pane. --- spec/System/TestWeightedScore_spec.lua | 31 +++++++++++++++++++++++++- src/Classes/ItemDBControl.lua | 21 +++++++++-------- src/Classes/NotableDBControl.lua | 15 +++++++++++-- src/Classes/TreeTab.lua | 29 ++++++++++++------------ src/Modules/WeightedScore.lua | 19 ++++++++++++++++ 5 files changed, 88 insertions(+), 27 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 58ef0d621e1..b2f65219b9d 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -179,7 +179,7 @@ describe("WeightedScore module", function() end) describe("WeightedScore — TradeQueryGenerator delegation", function() - local mock_queryGen = new("TradeQueryGenerator", { + local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {}, GetTradeStatusOption = function() return "online" end, }) @@ -379,4 +379,33 @@ describe("WeightedScore — tree integration", function() assert.is_not_nil(found, "WeightedScore entry should appear in fallbackWeightsList candidates") assert.is_function(found.getValue, "getValue must be propagated into the dropdown entry") end) + + -- appendEditWeightsAction ----------------------------------------------- + + it("appendEditWeightsAction is a no-op when the list has no WeightedScore entry", function() + local list = { + { label = "Sort by Name", sortMode = "name" }, + { label = "Sort by Life", sortMode = "Life" }, + } + local called = false + WeightedScore.appendEditWeightsAction(list, function() called = true end) + assert.are.equal(2, #list) + assert.is_false(called) + end) + + it("appendEditWeightsAction appends an action entry when WeightedScore is present", function() + local list = { + { label = "Sort by Name", sortMode = "name" }, + { label = "Sort by Weighted Score", sortMode = "WeightedScore", isWeightedScore = true }, + } + local opened = false + WeightedScore.appendEditWeightsAction(list, function() opened = true end) + assert.are.equal(3, #list) + local entry = list[3] + assert.is_true(entry.isAction) + assert.is_function(entry.action) + assert.is_string(entry.label) + entry.action() + assert.is_true(opened, "calling entry.action must invoke the openEditor callback") + end) end) diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index ec9913a57c5..2e211d7661e 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -49,19 +49,16 @@ function ItemDBClass:ItemDBControl(anchor, rect, itemsTab, db, dbType) end) if dbType == "UNIQUE" then self.controls.sort = new("DropDownControl"):DropDownControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, baseY + 20, 179, 18}, self.sortDropList, function(index, value) - self:SetSortMode(value.sortMode) + if value.isAction then + value.action() + self.controls.sort:SelByValue(self.sortMode, "sortMode") + else + self:SetSortMode(value.sortMode) + end end) self.controls.league = new("DropDownControl"):DropDownControl({"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, self.leagueList, function(index, value) self.listBuildFlag = true end) - self.controls.editWeights = new("ButtonControl"):ButtonControl({"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, "Edit Weights...", function() - local tq = self.itemsTab.tradeQuery - if tq then - tq:SetStatWeights(nil, function() self.listBuildFlag = true end) - end - end) - self.controls.league.shown = function() return self.sortMode ~= "WeightedScore" end - self.controls.editWeights.shown = function() return self.sortMode == "WeightedScore" end self.controls.requirement = new("DropDownControl"):DropDownControl({"LEFT",self.controls.sort,"BOTTOMLEFT"}, {0, 11, 179, 18}, { "Any requirements", "Current level", "Current attributes", "Current useable" }, function(index, value) self.listBuildFlag = true end) @@ -240,6 +237,12 @@ function ItemDBClass:BuildSortOrder() }) end end + WeightedScore.appendEditWeightsAction(self.sortDropList, function() + local tq = self.itemsTab.tradeQuery + if tq then + tq:SetStatWeights(nil, function() self.listBuildFlag = true end) + end + end) wipeTable(self.sortOrder) if self.controls.sort then self.controls.sort:CheckDroppedWidth(true) diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index a03ccd3599e..92ffdeafc69 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -37,7 +37,12 @@ function NotableDBClass:NotableDBControl(anchor, rect, itemsTab, db, dbType) self.sortOrder = { } self.sortMode = "NAME" self.controls.sort = new("DropDownControl"):DropDownControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -22, 360, 18}, self.sortDropList, function(index, value) - self:SetSortMode(value.sortMode) + if value.isAction then + value.action() + self.controls.sort:SelByValue(self.sortMode, "sortMode") + else + self:SetSortMode(value.sortMode) + end end) self.controls.search = new("EditControl"):EditControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -2, 258, 18}, "", "Search", "%c", 100, function() self.listBuildFlag = true @@ -104,6 +109,12 @@ function NotableDBClass:BuildSortOrder() }) end end + WeightedScore.appendEditWeightsAction(self.sortDropList, function() + local tq = self.itemsTab.tradeQuery + if tq then + tq:SetStatWeights(nil, function() self.listBuildFlag = true end) + end + end) wipeTable(self.sortOrder) if self.controls.sort then self.controls.sort.selIndex = 1 @@ -298,4 +309,4 @@ end ---@param node table function NotableDBClass:OnSelCopy(index, node) Copy(item.dn) -end \ No newline at end of file +end diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 8203629be80..113c30c7fe7 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -255,7 +255,14 @@ function TreeTabClass:TreeTab(build) -- Control for selecting the power stat to sort by (Defense, DPS, etc) self.controls.treeHeatMapStatSelect = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT" }, { 8, 0, 150, 20 }, nil, function(index, value) - self:SetPowerCalc(value) + if value.isAction then + value.action() + if self.build.calcsTab.powerStat then + self.controls.treeHeatMapStatSelect:SelByValue(self.build.calcsTab.powerStat.stat, "stat") + end + else + self:SetPowerCalc(value) + end end) self.controls.treeHeatMap.tooltipText = function() local offCol, defCol = main.nodePowerTheme:match("(%a+)/(%a+)") @@ -268,6 +275,12 @@ function TreeTabClass:TreeTab(build) t_insert(self.powerStatList, stat) end end + WeightedScore.appendEditWeightsAction(self.powerStatList, function() + local tq = self.build.itemsTab.tradeQuery + if tq then + tq:SetStatWeights(nil, function() self:SetPowerCalc(self.build.calcsTab.powerStat) end) + end + end) -- Show/Hide Power Report Button self.controls.powerReport = new("ButtonControl"):ButtonControl({ "LEFT", self.controls.treeHeatMapStatSelect, "RIGHT" }, { 8, 0, 150, 20 }, @@ -275,18 +288,6 @@ function TreeTabClass:TreeTab(build) self.controls.powerReportList.shown = not self.controls.powerReportList.shown end) - -- Edit Weights button (only shown when Weighted Score heatmap mode is active) - self.controls.editWeights = new("ButtonControl"):ButtonControl( - { "LEFT", self.controls.powerReport, "RIGHT" }, { 8, 0, 130, 20 }, - "Edit Weights...", - function() - local tq = self.build.itemsTab.tradeQuery - if tq then - tq:SetStatWeights(nil, function() self:SetPowerCalc(self.build.calcsTab.powerStat) end) - end - end) - self.controls.editWeights.shown = false - -- Power Report List local yPos = self.controls.treeHeatMap.y == 0 and self.controls.specSelect.height + 4 or self.controls.specSelect.height * 2 + 8 self.controls.powerReportList = new("PowerReportListControl"):PowerReportListControl({ "TOPLEFT", self.controls.specSelect, "BOTTOMLEFT" }, { 0, yPos, 700, 170 }, function(selectedNode) @@ -477,7 +478,6 @@ function TreeTabClass:Draw(viewPort, inputEvents) self.controls.treeHeatMap.state = self.viewer.showHeatMap self.controls.treeHeatMapStatSelect.shown = self.viewer.showHeatMap - self.controls.editWeights.shown = self.viewer.showHeatMap and self.build.calcsTab.powerStat and self.build.calcsTab.powerStat.isWeightedScore or false self.controls.treeHeatMapStatSelect.list = self.powerStatList self.controls.treeHeatMapStatSelect.selIndex = 1 self.controls.treeHeatMapStatSelect:CheckDroppedWidth(true) @@ -1063,7 +1063,6 @@ function TreeTabClass:SetPowerCalc(powerStat) self.build.buildFlag = true self.build.calcsTab.powerBuildFlag = true self.build.calcsTab.powerStat = powerStat - self.controls.editWeights.shown = powerStat and powerStat.isWeightedScore or false self.controls.powerReportList:SetReport(powerStat, nil) -- Remove old toast and clear dismissed state so toast can show for new power report if self.powerBuilderToastId then diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index 752c67fc0d5..0eb0cdc4035 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -71,4 +71,23 @@ function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) return meanStatDiff end +-- Append a contextual "Edit Weights..." action to a sort dropdown list when the +-- list contains the WeightedScore entry. Lets every WS-aware sort surface share +-- the same affordance without each one adding its own button. +function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) + local hasWeightedScore = false + for _, entry in ipairs(sortDropList) do + if entry.isWeightedScore then + hasWeightedScore = true + break + end + end + if not hasWeightedScore then return end + table.insert(sortDropList, { + label = colorCodes.TIP .. "Edit Weights...", + isAction = true, + action = openEditor, + }) +end + return WeightedScore From 9360b621de323c55aeb1db276b1f8f5794fe20ed Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 20:54:14 +0200 Subject: [PATCH 11/37] Preserve output stat semantics for Weighted Score Restore the shared accessor, transforms, and Full DPS fallback expected by Trade Query. --- spec/System/TestWeightedScore_spec.lua | 11 ++++------- src/Modules/WeightedScore.lua | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index b2f65219b9d..1fccbc3ec8d 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -311,13 +311,10 @@ describe("WeightedScore — tree integration", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) assert.is_function(stat.getValue) - local calcFunc = build.calcsTab:GetMiscCalculator(build) - local baseOutput = calcFunc() - -- Synthesize a "better" output by doubling FullDPS relative to base - local betterOutput = setmetatable({}, { __index = baseOutput }) - betterOutput.FullDPS = (baseOutput.FullDPS or 0) * 2 + 1 - local baseScore = stat.getValue(baseOutput, build) - local betterScore = stat.getValue(betterOutput, build) + local baseOutput = { FullDPS = 100, TotalEHP = 100 } + local betterOutput = { FullDPS = 201, TotalEHP = 100 } + local baseScore = stat.getValue(baseOutput, build, baseOutput) + local betterScore = stat.getValue(betterOutput, build, baseOutput) assert.is_true(betterScore > baseScore) end) diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index 0eb0cdc4035..089f4cdd9ed 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -40,7 +40,8 @@ function WeightedScore.weightsNeedFullDPS(weights) end -- Compute a weighted ratio score comparing newOutput to baseOutput. --- Each stat contributes: weight * (newOutput[stat] / baseOutput[stat]). +-- Each stat contributes: weight * (new output / base output), using the +-- shared power-stat accessor so minion and transformed stats match Trade Query. -- A neutral candidate (same as base) scores approximately sum(weights). -- Higher score means the candidate is better. -- Missing or zero stats are handled safely (no crash, no infinite values). @@ -49,9 +50,9 @@ function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) local function ratioModSums(...) local baseModSum = 0 local newModSum = 0 - for _, mod in ipairs({ ... }) do - baseModSum = baseModSum + (baseOutput[mod] or 0) - newModSum = newModSum + (newOutput[mod] or 0) + for _, statTable in ipairs({ ... }) do + baseModSum = baseModSum + data.powerStatList.GetFromOutput(baseOutput, statTable, true) + newModSum = newModSum + data.powerStatList.GetFromOutput(newOutput, statTable, true) end if baseModSum == math.huge then return 0 @@ -62,11 +63,17 @@ function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) end end for _, statTable in ipairs(weights) do + local modSumRatio if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then -- FullDPS fallback: use combined DPS components when FullDPS is not directly available - meanStatDiff = meanStatDiff + (ratioModSums("TotalDPS", "TotalDotDPS", "CombinedDPS") or 0) * statTable.weightMult + modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) + else + modSumRatio = ratioModSums(statTable) + end + if statTable.transform then + modSumRatio = statTable.transform(modSumRatio) end - meanStatDiff = meanStatDiff + (ratioModSums(statTable.stat) or 0) * statTable.weightMult + meanStatDiff = meanStatDiff + modSumRatio * statTable.weightMult end return meanStatDiff end From fcfee419fcee93789adbc87197d85b40f02de8d4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 20:57:47 +0200 Subject: [PATCH 12/37] Limit Weighted Score to supported sort menus Avoid generating a Minion Weighted Score entry and hide the score from modifier menus that can only read stored output fields. --- spec/System/TestWeightedScore_spec.lua | 4 ++++ src/Classes/ItemsTab.lua | 2 +- src/Modules/Data.lua | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 1fccbc3ec8d..dd352477d8b 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -246,6 +246,10 @@ describe("WeightedScore — tree integration", function() assert.is_true(stat.isWeightedScore) end) + it("does not create a Minion WeightedScore entry", function() + assert.is_nil(findStat("MinionWeightedScore")) + end) + -- Pass: power builder runs to completion without Lua error -- Fail: a crash in CalculatePowerStat's isWeightedScore branch it("power builder completes without error using WeightedScore stat", function() diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba58..3b1e79d023c 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -77,7 +77,7 @@ local function buildModSortList() local sortList = { { label = "Default", stat = nil } } local sortStats = { } for _, entry in ipairs(data.powerStatList) do - if entry.stat and not entry.ignoreForNodes then + if entry.stat and not entry.ignoreForNodes and not entry.isWeightedScore then t_insert(sortList, { label = entry.label, stat = entry.stat }) sortStats[entry.stat] = entry end diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index e157fa32f4a..eccf8196331 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -234,7 +234,7 @@ local minionNonApplicableStats = { } for i = 1, #data.powerStatList do local statEntry = data.powerStatList[i] - if (not statEntry.stat) or statEntry.stat:match("DPS") or minionNonApplicableStats[statEntry.stat] then + if (not statEntry.stat) or statEntry.isWeightedScore or statEntry.stat:match("DPS") or minionNonApplicableStats[statEntry.stat] then goto statContinue end local minionStat = copyTable(statEntry) From 4defe17f70d285ae4d3bbc687b1f8b40b6097642 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:02:18 +0200 Subject: [PATCH 13/37] Preserve Item DB ranking for Weighted Score Keep negative scores and request Full DPS only when the active weights need it. --- spec/System/TestItemDBControl_spec.lua | 97 ++++++++++++++++++++++++++ src/Classes/ItemDBControl.lua | 5 +- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua index 98d4605c4a2..6fa9895ef17 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -62,6 +62,103 @@ describe("ItemDBControl", function() assert.are.equal(-math.huge, invalidItem.measuredPower) end) + it("preserves negative WeightedScore results and skips unneeded FullDPS", function() + local function makeItem(name) + return { + name = name, + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + end + local betterItem = makeItem("Better Item") + local worseItem = makeItem("Worse Item") + local invalidItem = makeItem("Invalid Item") + local takenDamage = { + [betterItem] = 80, + [worseItem] = 120, + } + local requestedFullDPS = { } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + tradeQuery = { + statSortSelectionList = { + { stat = "PhysicalTakenHit", weightMult = 1, transform = function(value) return -value end }, + }, + }, + IsItemValidForSlot = function(_, item) + return item ~= invalidItem + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(args, useFullDPS) + table.insert(requestedFullDPS, useFullDPS) + return { PhysicalTakenHit = takenDamage[args.repItem] } + end, { PhysicalTakenHit = 100 } + end, + }, + } + local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { + list = { invalidItem, betterItem, worseItem }, + }, "RARE") + control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.equal(betterItem, control.list[1]) + assert.are.equal(worseItem, control.list[2]) + assert.are.equal(invalidItem, control.list[3]) + assert.are.equal(-0.8, betterItem.measuredPower) + assert.are.equal(-1.2, worseItem.measuredPower) + assert.are.equal(-math.huge, invalidItem.measuredPower) + assert.are.same({ false, false }, requestedFullDPS) + end) + + it("requests FullDPS for WeightedScore when active weights need it", function() + local item = { + name = "Full DPS Item", + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + local requestedFullDPS = { } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + tradeQuery = { statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } }, + IsItemValidForSlot = function() + return true + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(_, useFullDPS) + table.insert(requestedFullDPS, useFullDPS) + return { FullDPS = 120 } + end, { FullDPS = 100 } + end, + }, + } + local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") + control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.same({ true }, requestedFullDPS) + end) + it("searches Foulborn modifier text without case sensitivity", function() local item = new("Item"):Item([[ Rarity: Unique diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 2e211d7661e..5e7f1ca2212 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -268,11 +268,12 @@ function ItemDBClass:ListBuilder() local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) local weights = WeightedScore.getWeights(self.itemsTab.build) + local useFullDPS = WeightedScore.weightsNeedFullDPS(weights) for itemIndex, item in ipairs(list) do - item.measuredPower = 0 + item.measuredPower = -math.huge for slotName, slot in pairs(self.itemsTab.slots) do if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then - local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }) + local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }, useFullDPS) local score = WeightedScore.computeRatioScore(calcBase, output, weights) item.measuredPower = m_max(item.measuredPower, score) end From 053402312aabd068dad2b40a52a92e52dd5b0650 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:10:34 +0200 Subject: [PATCH 14/37] Preserve comparison context for Weighted Score Calculate candidates with the matching Full DPS context and retain the baseline output for Timeless Jewel fallback scoring. --- spec/System/TestWeightedScore_spec.lua | 21 --------------------- src/Classes/CompareTab.lua | 18 ++++++++++++++---- src/Classes/TreeTab.lua | 9 +++++++-- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index dd352477d8b..50b4b42b88e 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -360,27 +360,6 @@ describe("WeightedScore — tree integration", function() assert.is_true(score ~= 0) end) - -- Pass: fallbackWeightsList entries for WeightedScore carry getValue - -- Fail: if getValue is not copied into the dropdown entry, generateFallbackWeights - -- receives selection.getValue = nil and falls back to output["WeightedScore"] - -- which is always nil, producing weight = -100 for every node - it("WeightedScore fallbackWeightsList entry carries getValue callback", function() - local found = nil - for _, entry in pairs(data.powerStatList) do - if entry.stat == "WeightedScore" and not entry.ignoreForItems and entry.label ~= "Name" then - found = { - label = "Sort by " .. entry.label, - stat = entry.stat, - transform = entry.transform, - getValue = entry.getValue, - } - break - end - end - assert.is_not_nil(found, "WeightedScore entry should appear in fallbackWeightsList candidates") - assert.is_function(found.getValue, "getValue must be propagated into the dropdown entry") - end) - -- appendEditWeightsAction ----------------------------------------------- it("appendEditWeightsAction is a no-op when the list has no WeightedScore entry", function() diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index bcd470eab79..d2e09b12b93 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -15,6 +15,7 @@ local calcsHelpers = require("Classes.CompareCalcsHelpers") local buildListHelpers = require("Modules.BuildListHelpers") local itemSlotHelper = require("Modules.ItemSlotHelper") local configVisibility = require("Modules.ConfigVisibility") +local WeightedScore = require("Modules.WeightedScore") -- Node IDs below this value are normal passive tree nodes; IDs at or above are cluster jewel nodes local CLUSTER_NODE_OFFSET = 65536 @@ -2515,10 +2516,15 @@ end -- Coroutine: calculate power of compared build elements against primary build function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories) local results = {} + local weightedScoreWeights = powerStat.isWeightedScore and WeightedScore.getWeights(self.primaryBuild) local useFullDPS = powerStat.stat == "FullDPS" + or (powerStat.isWeightedScore and WeightedScore.weightsNeedFullDPS(weightedScoreWeights)) -- Get calculator for primary build local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + if useFullDPS then + calcBase = calcFunc(nil, true) + end -- Find display stat for formatting local displayStat = nil @@ -2621,7 +2627,8 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- Get baseline stat value for percentage calculation - local baseStatValue = data.powerStatList.GetFromOutput(calcBase, powerStat) + local baseStatValue = powerStat.getValue and powerStat.getValue(calcBase, self.primaryBuild, calcBase) + or data.powerStatList.GetFromOutput(calcBase, powerStat) -- Helper to format an impact value and compute percentage local function formatImpact(impact) @@ -2918,7 +2925,8 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- Get a fresh calculator with the added group (pcall to guarantee cleanup) local ok, gemCalcFunc, gemCalcBase = pcall(function() - return self.calcs.getMiscCalculator(self.primaryBuild) + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + return calcFunc, useFullDPS and calcFunc(nil, true) or calcBase end) -- Always remove the temporarily added group @@ -3001,7 +3009,8 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories self.primaryBuild.buildFlag = true local ok, sgCalcFunc, sgCalcBase = pcall(function() - return self.calcs.getMiscCalculator(self.primaryBuild) + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + return calcFunc, useFullDPS and calcFunc(nil, true) or calcBase end) -- Always remove the temporarily added gem @@ -3070,7 +3079,8 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local ok, cfgCalcFunc, cfgCalcBase = pcall(function() self.primaryBuild.configTab:BuildModList() self.primaryBuild.buildFlag = true - return self.calcs.getMiscCalculator(self.primaryBuild) + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + return calcFunc, useFullDPS and calcFunc(nil, true) or calcBase end) -- Always restore original value diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 113c30c7fe7..b06a66af4d7 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1965,10 +1965,15 @@ function TreeTabClass:FindTimelessJewel() local function generateFallbackWeights(nodes, powerStat) local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) + local useFullDPS = powerStat.stat == "FullDPS" + or (powerStat.isWeightedScore and WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(self.build))) + if useFullDPS then + calcBase = calcFunc(nil, true) + end local newList = { } local function getStatValue(output) if powerStat.getValue then - return powerStat.getValue(output, self.build) + return powerStat.getValue(output, self.build, calcBase) end return data.powerStatList.GetFromOutput(output, powerStat) end @@ -1979,7 +1984,7 @@ function TreeTabClass:FindTimelessJewel() local nodeLines = newNode.node or { newNode } for i = 1, #nodeLines do local node = nodeLines[i] - local nodeOutput = calcFunc({ addNodes = { [node] = true } }) + local nodeOutput = calcFunc({ addNodes = { [node] = true } }, useFullDPS) local nodePower = getStatValue(nodeOutput) -- avoid infinity if basePower == 0 then From 5b224f30df05e4a82d47e43fb7ed67c4c1cb3257 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:13:44 +0200 Subject: [PATCH 15/37] Test combined Weighted Score inputs --- spec/System/TestWeightedScore_spec.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 50b4b42b88e..1b7d4b65a3e 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -78,6 +78,18 @@ describe("WeightedScore module", function() assert.are.equal(0.5, score) end) + it("combines multiple stats with their individual weight multipliers", function() + local base = { TotalDPS = 100, TotalEHP = 200 } + local candidate = { TotalDPS = 150, TotalEHP = 250 } + local weights = { + { stat = "TotalDPS", weightMult = 1.5 }, + { stat = "TotalEHP", weightMult = 0.25 }, + } + + -- (150 / 100) * 1.5 + (250 / 200) * 0.25 = 2.5625 + assert.are.equal(2.5625, WeightedScore.computeRatioScore(base, candidate, weights)) + end) + it("empty weights always scores 0", function() local base = { TotalDPS = 1000 } local new = { TotalDPS = 5000 } From 9489598c54e08f7f67ac959197461ae94b41d801 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:16:50 +0200 Subject: [PATCH 16/37] Share the default Weighted Score weights --- src/Classes/TradeQuery.lua | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index d90ab0e5efc..cc85db3be37 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = require("Modules.ItemSlotHelper") +local WeightedScore = require("Modules.WeightedScore") local get_time = os.time local t_insert = table.insert @@ -256,23 +257,24 @@ function TradeQueryClass:PullCXData() end local function initStatSortSelectionList(list) - t_insert(list, { - label = "Full DPS", - stat = "FullDPS", - weightMult = 1.0, - }) - t_insert(list, { - label = "Effective Hit Pool", - stat = "TotalEHP", - weightMult = 0.5, - }) + for _, weight in ipairs(WeightedScore.defaultWeights()) do + t_insert(list, weight) + end end -- we do not want to overwrite previous list if the new list is the default, e.g. hitting reset multiple times in a row local function isSameAsDefaultList(list) - return list and #list == 2 - and list[1].stat == "FullDPS" and list[1].weightMult == 1.0 - and list[2].stat == "TotalEHP" and list[2].weightMult == 0.5 + local defaultWeights = WeightedScore.defaultWeights() + if not list or #list ~= #defaultWeights then + return false + end + for index, weight in ipairs(defaultWeights) do + local selectedWeight = list[index] + if selectedWeight.stat ~= weight.stat or selectedWeight.weightMult ~= weight.weightMult then + return false + end + end + return true end -- Opens the item pricing popup From fbd25a66617218a7462ce1cfd61fa1c3ac35feac Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:24:08 +0200 Subject: [PATCH 17/37] Use Full DPS for Anoint ranking --- spec/System/TestNotableDBControl_spec.lua | 37 +++++++++++++++++++++++ src/Classes/NotableDBControl.lua | 6 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 spec/System/TestNotableDBControl_spec.lua diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua new file mode 100644 index 00000000000..7a5c31222f1 --- /dev/null +++ b/spec/System/TestNotableDBControl_spec.lua @@ -0,0 +1,37 @@ +describe("NotableDBControl", function() + it("requests FullDPS when sorting by WeightedScore with FullDPS weights", function() + local notable = { + dn = "Full DPS Notable", + sd = {}, + recipe = { "Amber Oil" }, + modKey = "NotableFullDPS", + } + local requestedFullDPS = {} + local itemsTab = { + displayItem = { base = { type = "Amulet" } }, + tradeQuery = { statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } }, + anointItem = function(_, node) + return node + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(args, useFullDPS) + table.insert(requestedFullDPS, useFullDPS) + return { FullDPS = args.repItem and 120 or 100 } + end + end, + }, + } + local control = new("NotableDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANNOINT") + control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.same({ true, true }, requestedFullDPS) + assert.are.equal(notable, control.list[1]) + end) +end) diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index 92ffdeafc69..de8c9098261 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -147,13 +147,15 @@ function NotableDBClass:ListBuilder() local start = GetTime() local calcFunc = self.itemsTab.build.calcsTab:GetMiscCalculator() local itemType = self.itemsTab.displayItem.base.type - local calcBase = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(nil) }) local weights = self.sortDetail.isWeightedScore and WeightedScore.getWeights(self.itemsTab.build) + local useFullDPS = self.sortDetail.stat == "FullDPS" + or (self.sortDetail.isWeightedScore and WeightedScore.weightsNeedFullDPS(weights)) + local calcBase = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(nil) }, useFullDPS) self.sortMaxPower = 0 for nodeIndex, node in ipairs(list) do node.measuredPower = 0 if node.modKey ~= "" then - local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }) + local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }, useFullDPS) if self.sortDetail.isWeightedScore then node.measuredPower = WeightedScore.computeRatioScore(calcBase, output, weights) else From 2c1d0633dac05fc21e30cdf5c3426f45035505bc Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:26:12 +0200 Subject: [PATCH 18/37] Save Weighted Score weight edits --- spec/System/TestTradeQuery_spec.lua | 31 +++++++++++++++++++++++++++++ src/Classes/TradeQuery.lua | 1 + 2 files changed, 32 insertions(+) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c700a332cf8..9dda2c9a2c2 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -176,4 +176,35 @@ describe("TradeQuery", function() assert.are.equals(1.2, result) end) end) + + describe("SetStatWeights", function() + it("marks the build modified after saving changed weights", function() + local capturedControls + local originalOpenPopup = main.OpenPopup + local originalClosePopup = main.ClosePopup + main.OpenPopup = function(_, _, _, _, controls) + capturedControls = controls + end + main.ClosePopup = function() end + + local itemsTab = {} + local tradeQuery = new("TradeQuery", itemsTab) + local ok, errMsg = pcall(function() + tradeQuery:SetStatWeights() + for _, entry in ipairs(capturedControls.ListControl.list) do + if entry.stat.stat == "FullDPS" then + entry.stat.weightMult = 0.75 + break + end + end + capturedControls.finalise.onClick() + end) + main.OpenPopup = originalOpenPopup + main.ClosePopup = originalClosePopup + + assert.is_true(ok, errMsg) + assert.is_true(itemsTab.modFlag) + assert.are.equal(0.75, tradeQuery.statSortSelectionList[1].weightMult) + end) + end) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index cc85db3be37..caeaba88137 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -773,6 +773,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList, onSave) if (#statSortSelectionList) > 0 then --THIS SHOULD REALLY GIVE A WARNING NOT JUST USE PREVIOUS self.statSortSelectionList = statSortSelectionList + self.itemsTab.modFlag = true end for row_idx in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) From 962c1114abe2c08adacd88d902c1e8e868faeec1 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 09:17:11 +0200 Subject: [PATCH 19/37] Put Weighted Score last in menus --- spec/System/TestWeightedScore_spec.lua | 6 ++++-- src/Modules/Data.lua | 23 ++++++++++++++--------- src/Modules/WeightedScore.lua | 22 +++++++++------------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 1b7d4b65a3e..6f7e661954b 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -256,6 +256,7 @@ describe("WeightedScore — tree integration", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) assert.is_true(stat.isWeightedScore) + assert.are.equal("WeightedScore", data.powerStatList[#data.powerStatList].stat) end) it("does not create a Minion WeightedScore entry", function() @@ -385,7 +386,7 @@ describe("WeightedScore — tree integration", function() assert.is_false(called) end) - it("appendEditWeightsAction appends an action entry when WeightedScore is present", function() + it("appendEditWeightsAction inserts an action before WeightedScore", function() local list = { { label = "Sort by Name", sortMode = "name" }, { label = "Sort by Weighted Score", sortMode = "WeightedScore", isWeightedScore = true }, @@ -393,10 +394,11 @@ describe("WeightedScore — tree integration", function() local opened = false WeightedScore.appendEditWeightsAction(list, function() opened = true end) assert.are.equal(3, #list) - local entry = list[3] + local entry = list[2] assert.is_true(entry.isAction) assert.is_function(entry.action) assert.is_string(entry.label) + assert.is_true(list[3].isWeightedScore) entry.action() assert.is_true(opened, "calling entry.action must invoke the openEditor callback") end) diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index eccf8196331..97df105b416 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -178,15 +178,6 @@ data.powerStatList = { { stat="BlockChance", label="Block Chance" }, { stat="SpellBlockChance", label="Spell Block Chance" }, { stat="SpellSuppressionChance", label="Spell Suppression Chance" }, - { stat="WeightedScore", label="Weighted Score", isWeightedScore=true, getValue=function(output, build, calcBase) - local weights = WeightedScore.getWeights(build) - local buildBase = calcBase - if not buildBase then - local _, cachedBuildBase = build.calcsTab:GetMiscCalculator() - buildBase = cachedBuildBase - end - return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 - end }, } ---@param output any Calc output @@ -243,6 +234,20 @@ for i = 1, #data.powerStatList do t_insert(data.powerStatList, minionStat) ::statContinue:: end +t_insert(data.powerStatList, { + stat="WeightedScore", + label="Weighted Score", + isWeightedScore=true, + getValue=function(output, build, calcBase) + local weights = WeightedScore.getWeights(build) + local buildBase = calcBase + if not buildBase then + local _, cachedBuildBase = build.calcsTab:GetMiscCalculator() + buildBase = cachedBuildBase + end + return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 + end, +}) data.misc = { -- magic numbers ServerTickTime = 0.033, ServerTickRate = 1 / 0.033, diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index 089f4cdd9ed..fcd7ddead69 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -78,23 +78,19 @@ function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) return meanStatDiff end --- Append a contextual "Edit Weights..." action to a sort dropdown list when the --- list contains the WeightedScore entry. Lets every WS-aware sort surface share --- the same affordance without each one adding its own button. +-- Insert a contextual "Edit Weights..." action immediately before WeightedScore +-- so the score remains the final metric in every compatible sort dropdown. function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) - local hasWeightedScore = false - for _, entry in ipairs(sortDropList) do + for index, entry in ipairs(sortDropList) do if entry.isWeightedScore then - hasWeightedScore = true - break + table.insert(sortDropList, index, { + label = colorCodes.TIP .. "Edit Weights...", + isAction = true, + action = openEditor, + }) + return end end - if not hasWeightedScore then return end - table.insert(sortDropList, { - label = colorCodes.TIP .. "Edit Weights...", - isAction = true, - action = openEditor, - }) end return WeightedScore From 5d8b4f7259565ccda01e91ac4115d1587013883f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 09:30:47 +0200 Subject: [PATCH 20/37] Put Edit Weights after Weighted Score --- spec/System/TestWeightedScore_spec.lua | 6 +++--- src/Modules/WeightedScore.lua | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 6f7e661954b..8284694f523 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -386,7 +386,7 @@ describe("WeightedScore — tree integration", function() assert.is_false(called) end) - it("appendEditWeightsAction inserts an action before WeightedScore", function() + it("appendEditWeightsAction appends an action after WeightedScore", function() local list = { { label = "Sort by Name", sortMode = "name" }, { label = "Sort by Weighted Score", sortMode = "WeightedScore", isWeightedScore = true }, @@ -394,11 +394,11 @@ describe("WeightedScore — tree integration", function() local opened = false WeightedScore.appendEditWeightsAction(list, function() opened = true end) assert.are.equal(3, #list) - local entry = list[2] + local entry = list[3] assert.is_true(entry.isAction) assert.is_function(entry.action) assert.is_string(entry.label) - assert.is_true(list[3].isWeightedScore) + assert.is_true(list[2].isWeightedScore) entry.action() assert.is_true(opened, "calling entry.action must invoke the openEditor callback") end) diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index fcd7ddead69..2fcd7f8759c 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -78,12 +78,12 @@ function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) return meanStatDiff end --- Insert a contextual "Edit Weights..." action immediately before WeightedScore --- so the score remains the final metric in every compatible sort dropdown. +-- Append a contextual "Edit Weights..." action after WeightedScore so the +-- score remains the final metric while its configuration stays adjacent. function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) - for index, entry in ipairs(sortDropList) do + for _, entry in ipairs(sortDropList) do if entry.isWeightedScore then - table.insert(sortDropList, index, { + table.insert(sortDropList, { label = colorCodes.TIP .. "Edit Weights...", isAction = true, action = openEditor, From a7a9263a6b7c7be47c3be434bc6a8a377d8fcc47 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 11:18:41 +0200 Subject: [PATCH 21/37] Sort item modifiers by Weighted Score Evaluate contextual metrics against the displayed item baseline so Weighted Score and Full DPS rank candidates correctly. --- spec/System/TestWeightedScore_spec.lua | 60 ++++++++++++++ src/Classes/ItemsTab.lua | 106 +++++++++++++++++-------- src/Modules/Data.lua | 27 ++++++- src/Modules/WeightedScore.lua | 19 +++++ 4 files changed, 177 insertions(+), 35 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 8284694f523..2f6a33796c8 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -335,6 +335,23 @@ describe("WeightedScore — tree integration", function() assert.is_true(betterScore > baseScore) end) + it("power stat helpers evaluate WeightedScore with its baseline and Full DPS requirement", function() + local weightedScore = findStat("WeightedScore") + local fullDPS = findStat("FullDPS") + local life = findStat("Life") + local baseOutput = { FullDPS = 100, TotalEHP = 100, TotalDPS = 0, TotalDotDPS = 0, CombinedDPS = 0 } + local betterOutput = { FullDPS = 120, TotalEHP = 100, TotalDPS = 0, TotalDotDPS = 0, CombinedDPS = 0 } + + assert.is_true(data.powerStatList.RequiresFullDPS(weightedScore, build)) + assert.is_true(data.powerStatList.RequiresFullDPS(fullDPS, build)) + assert.is_false(data.powerStatList.RequiresFullDPS(life, build)) + assert.is_true( + data.powerStatList.GetValue(betterOutput, weightedScore, build, baseOutput) + > data.powerStatList.GetValue(baseOutput, weightedScore, build, baseOutput) + ) + assert.are.equal(123, data.powerStatList.GetValue({ Life = 123 }, life, build, baseOutput)) + end) + it("getValue on WeightedScore entry reuses provided calcBase", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) @@ -402,4 +419,47 @@ describe("WeightedScore — tree integration", function() entry.action() assert.is_true(opened, "calling entry.action must invoke the openEditor callback") end) + + it("createSortHandler restores the metric and invalidates cached candidate scores after editing weights", function() + local list = { + { label = "Default", stat = nil }, + { label = "Weighted Score", stat = "WeightedScore", isWeightedScore = true }, + } + local candidates = { + { label = "Damage", scores = { damage = 2, defence = 1 } }, + { label = "Defence", scores = { damage = 1, defence = 2 } }, + } + local weight = "damage" + local selectedStat + local controls = { + sort = { + SelByValue = function(_, value) + selectedStat = value + end, + }, + } + local function applySort(stat) + for _, candidate in ipairs(candidates) do + candidate.sortValues = candidate.sortValues or { } + candidate.sortValue = candidate.sortValues[stat] or candidate.scores[weight] + candidate.sortValues[stat] = candidate.sortValue + end + table.sort(candidates, function(a, b) return a.sortValue > b.sortValue end) + end + local function clearSortValues() + for _, candidate in ipairs(candidates) do + candidate.sortValues = nil + end + end + local handler = WeightedScore.createSortHandler(list, controls, function(onSave) + weight = "defence" + onSave() + end, applySort, clearSortValues) + + handler(2, list[2]) + assert.are.equal("Damage", candidates[1].label) + handler(3, list[3]) + assert.are.equal("WeightedScore", selectedStat) + assert.are.equal("Defence", candidates[1].label) + end) end) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 3b1e79d023c..bc745c074ef 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -14,10 +14,10 @@ local m_min = math.min local m_ceil = math.ceil local m_floor = math.floor local m_modf = math.modf - local buySimilar = require("Classes.CompareBuySimilar") local addImplicit = require("Modules.AddImplicitPopup") local gemTooltip = require("Classes.GemTooltip") +local WeightedScore = require("Modules.WeightedScore") local rarityDropList = { { label = colorCodes.NORMAL.."Normal", rarity = "NORMAL" }, @@ -77,14 +77,22 @@ local function buildModSortList() local sortList = { { label = "Default", stat = nil } } local sortStats = { } for _, entry in ipairs(data.powerStatList) do - if entry.stat and not entry.ignoreForNodes and not entry.isWeightedScore then - t_insert(sortList, { label = entry.label, stat = entry.stat }) + if entry.stat and not entry.ignoreForNodes then + t_insert(sortList, { label = entry.label, stat = entry.stat, isWeightedScore = entry.isWeightedScore }) sortStats[entry.stat] = entry end end return sortList, sortStats end +local function getCandidateSortContext(itemsTab, statEntry) + local slotName = itemsTab.displayItem:GetPrimarySlot() + local useFullDPS = data.powerStatList.RequiresFullDPS(statEntry, itemsTab.build) + local calcFunc = itemsTab.build.calcsTab:GetMiscCalculator() + local calcBase = calcFunc({ repSlotName = slotName, repItem = itemsTab.displayItem }, useFullDPS) + return calcFunc, calcBase, slotName, useFullDPS +end + ---@class ItemsTab: UndoHandler, ControlHost, Control ---@field displayItem Item? local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Control") @@ -2854,7 +2862,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) item:BuildAndParseRaw() return item end - local function getSortValue(entry, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(entry, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) entry.sortValues = entry.sortValues or { } if entry.sortValues[stat] ~= nil then return entry.sortValues[stat] @@ -2876,7 +2884,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) entry.sortValues[stat] = value return value end @@ -2886,11 +2894,10 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end local selected = not selectFirst and enchantmentList[controls.enchantment.selIndex] or nil if stat then - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = self.build.calcsTab:GetMiscCalculator() - local useFullDPS = stat == "FullDPS" + local statEntry = sortStats[stat] + local calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) for _, entry in ipairs(enchantmentList) do - entry.sortValue = getSortValue(entry, stat, calcFunc, slotName, useFullDPS) + entry.sortValue = getSortValue(entry, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(enchantmentList, function(a, b) if a.sortValue ~= b.sortValue then @@ -2915,6 +2922,12 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) controls.enchantment:SetSel(1, true) end end + local function clearSortValues() + for _, entry in ipairs(enchantmentList) do + entry.sortValue = nil + entry.sortValues = nil + end + end if haveSkills then controls.skillLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {95, 20, 0, 16}, "^7Skill:") controls.skill = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {100, 20, 180, 18}, skillList, function(index, value) @@ -2949,9 +2962,12 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end end) controls.sortLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {350, 45, 0, 16}, "^7Sort by:") - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 45, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 45, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, function(onSave) + if self.tradeQuery then + self.tradeQuery:SetStatWeights(nil, onSave) + end + end, applySort, clearSortValues)) controls.enchantmentLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {95, 70, 0, 16}, "^7Enchantment:") controls.enchantment = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {100, 70, 495, 18}, enchantmentList) controls.enchantment.tooltipFunc = function(tooltip, mode, index) @@ -3216,7 +3232,7 @@ function ItemsTabClass:CorruptDisplayItem() control:SelByValue(entry.val, "mod") end end - local function getSortValue(entry, modType, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(entry, modType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) entry.sortValues = entry.sortValues or { } if entry.sortValues[stat] ~= nil then return entry.sortValues[stat] @@ -3237,17 +3253,17 @@ function ItemsTabClass:CorruptDisplayItem() end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) entry.sortValues[stat] = value return value end - local function sortModType(modType, stat, calcFunc, slotName, useFullDPS) + local function sortModType(modType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) if not implicitList[modType] then return end if stat then for _, entry in ipairs(implicitList[modType]) do - entry.sortValue = getSortValue(entry, modType, stat, calcFunc, slotName, useFullDPS) + entry.sortValue = getSortValue(entry, modType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(implicitList[modType], function(a, b) if a.sortValue ~= b.sortValue then @@ -3265,14 +3281,17 @@ function ItemsTabClass:CorruptDisplayItem() if not controls.implicit1 then return end - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = stat and self.build.calcsTab:GetMiscCalculator() or nil - local useFullDPS = stat == "FullDPS" + local statEntry + local calcFunc, calcBase, slotName, useFullDPS + if stat then + statEntry = sortStats[stat] + calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) + end if currentModType ~= "ScourgeUpside" then - sortModType(currentModType, stat, calcFunc, slotName, useFullDPS) + sortModType(currentModType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) else - sortModType("ScourgeUpside", stat, calcFunc, slotName, useFullDPS) - sortModType("ScourgeDownside", stat, calcFunc, slotName, useFullDPS) + sortModType("ScourgeUpside", stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) + sortModType("ScourgeDownside", stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end if currentModType ~= "ScourgeUpside" then buildCorruptLists(currentModType) @@ -3287,6 +3306,14 @@ function ItemsTabClass:CorruptDisplayItem() control:UpdateSearch() end end + local function clearSortValues() + for _, entries in pairs(implicitList) do + for _, entry in ipairs(entries) do + entry.sortValue = nil + entry.sortValues = nil + end + end + end local function corruptItem(addingImplicits) local item = new("Item"):Item(self.displayItem:BuildRaw()) item.id = self.displayItem.id @@ -3451,9 +3478,12 @@ function ItemsTabClass:CorruptDisplayItem() end) controls.source.enabled = #sourceList > 1 controls.sortLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {350, 20, 0, 16}, "^7Sort by:") - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, function(onSave) + if self.tradeQuery then + self.tradeQuery:SetStatWeights(nil, onSave) + end + end, applySort, clearSortValues)) local implicitRowSize = 20 local implicitYPos = 35 controls.implicitCannotBeChangedLabel = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 20, implicitYPos + implicitRowSize, 0, 20 }, "^7This Items Implicits Cannot Be Changed") @@ -3525,7 +3555,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() listMod.sortValues = nil end end - local function getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) listMod.sortValues = listMod.sortValues or { } if listMod.sortValues[stat] ~= nil then return listMod.sortValues[stat] @@ -3537,7 +3567,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) listMod.sortValues[stat] = value return value end @@ -3547,11 +3577,10 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() end local selected = not selectFirst and modList[controls.modSelect.selIndex] or nil if stat then - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = self.build.calcsTab:GetMiscCalculator() - local useFullDPS = stat == "FullDPS" + local statEntry = sortStats[stat] + local calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) for _, listMod in ipairs(modList) do - listMod.sortValue = getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + listMod.sortValue = getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(modList, function(a, b) if a.sortValue ~= b.sortValue then @@ -3576,6 +3605,12 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() controls.modSelect:SetSel(1, true) end end + local function clearSortValues() + for _, listMod in ipairs(modList) do + listMod.sortValue = nil + listMod.sortValues = nil + end + end local function buildDropRestricted(baseCategories, modDb) local base = self.displayItem.base local subTypeName = base.subType and base.type .. ": " .. base.subType @@ -3828,9 +3863,12 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() controls.sortLabel.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, function(onSave) + if self.tradeQuery then + self.tradeQuery:SetStatWeights(nil, onSave) + end + end, applySort, clearSortValues)) controls.sort.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 97df105b416..9ad2f8534b0 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -130,7 +130,7 @@ end data.powerStatList = { { stat=nil, label="Offence/Defence", combinedOffDef=true, ignoreForItems=true }, { stat=nil, label="Name", itemField="Name", ignoreForNodes=true, reverseSort=true, transform=function(value) return value:gsub("^The ","") end}, - { stat="FullDPS", label="Full DPS" }, + { stat="FullDPS", label="Full DPS", requiresFullDPS=true }, { stat="CombinedDPS", label="Combined DPS" }, { stat="TotalDPS", label="Hit DPS" }, { stat="WithImpaleDPS", label="Impale + Hit DPS" }, @@ -212,6 +212,28 @@ function data.powerStatList.GetFromOutput(output, statTable, skipTransform) return getEntry() end +---@param output any Calc output +---@param statTable StatTable Table with stats as in data.powerStatList +---@param build? table Build that owns the candidate calculation +---@param calcBase? table Output of the baseline calculation +---@return number +function data.powerStatList.GetValue(output, statTable, build, calcBase) + if statTable.getValue then + return statTable.getValue(output, build, calcBase) + end + return data.powerStatList.GetFromOutput(output, statTable) +end + +---@param statTable StatTable Table with stats as in data.powerStatList +---@param build? table Build that owns the candidate calculation +---@return boolean +function data.powerStatList.RequiresFullDPS(statTable, build) + if type(statTable.requiresFullDPS) == "function" then + return statTable.requiresFullDPS(build) + end + return statTable.requiresFullDPS == true +end + -- these stats don't exist on minions or generally don't exist on both player and minion local minionNonApplicableStats = { AverageDamage = true, @@ -238,6 +260,9 @@ t_insert(data.powerStatList, { stat="WeightedScore", label="Weighted Score", isWeightedScore=true, + requiresFullDPS=function(build) + return WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(build)) + end, getValue=function(output, build, calcBase) local weights = WeightedScore.getWeights(build) local buildBase = calcBase diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index 2fcd7f8759c..35a41f76cf6 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -93,4 +93,23 @@ function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) end end +function WeightedScore.createSortHandler(sortDropList, controls, openEditor, applySort, clearSortValues) + local activeSort = sortDropList[1] + WeightedScore.appendEditWeightsAction(sortDropList, function() + controls.sort:SelByValue(activeSort.stat, "stat") + openEditor(function() + clearSortValues() + applySort(activeSort.stat, true) + end) + end) + return function(index, value) + if value.isAction then + value.action() + else + activeSort = value + applySort(value.stat, true) + end + end +end + return WeightedScore From ec21b844c680722a573f02e06a84e258ba8a2e9c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 6 Aug 2026 12:42:59 +0200 Subject: [PATCH 22/37] Preserve the refresh callback after Reset Forward the callback when Reset reopens the editor so the next Save invalidates caller caches. --- spec/System/TestTradeQuery_spec.lua | 25 +++++++++++++++++++++++++ src/Classes/TradeQuery.lua | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9dda2c9a2c2..7b3b56f51e3 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -206,5 +206,30 @@ describe("TradeQuery", function() assert.is_true(itemsTab.modFlag) assert.are.equal(0.75, tradeQuery.statSortSelectionList[1].weightMult) end) + + it("preserves the save callback after resetting weights", function() + local capturedControls + local originalOpenPopup = main.OpenPopup + local originalClosePopup = main.ClosePopup + main.OpenPopup = function(_, _, _, _, controls) + capturedControls = controls + end + main.ClosePopup = function() end + + local callbackCount = 0 + local ok, errMsg = pcall(function() + local tradeQuery = new("TradeQuery", {}) + tradeQuery:SetStatWeights(nil, function() + callbackCount = callbackCount + 1 + end) + capturedControls.reset.onClick() + capturedControls.finalise.onClick() + end) + main.OpenPopup = originalOpenPopup + main.ClosePopup = originalClosePopup + + assert.is_true(ok, errMsg) + assert.are.equal(1, callbackCount) + end) end) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index caeaba88137..240702ebf72 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -796,7 +796,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList, onSave) self.statSortSelectionList = { } initStatSortSelectionList(self.statSortSelectionList) main:ClosePopup() - self:SetStatWeights(previousSelection) + self:SetStatWeights(previousSelection, onSave) end) main:OpenPopup(420, popupHeight, "Stat Weight Multipliers", controls) end From beedd99715ee8048652916e4895e097af6e41afe Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 6 Aug 2026 23:04:36 +0200 Subject: [PATCH 23/37] Fix weighted score sorting for crafted affixes Evaluate crafted prefix and suffix candidates through the contextual stat API so Weighted Score uses its baseline and Full DPS requirements instead of tying every modifier at zero. --- spec/System/TestWeightedScore_spec.lua | 79 ++++++++++++++++++++++++++ src/Classes/ItemsTab.lua | 19 +++++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 2f6a33796c8..ff58dff539b 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -463,3 +463,82 @@ describe("WeightedScore — tree integration", function() assert.are.equal("Defence", candidates[1].label) end) end) + +describe("WeightedScore — crafted affix sorting", function() + local originalGetMiscCalculator + local originalGetValue + + before_each(function() + newBuild() + originalGetMiscCalculator = build.calcsTab.GetMiscCalculator + originalGetValue = data.powerStatList.GetValue + end) + + after_each(function() + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + data.powerStatList.GetValue = originalGetValue + end) + + it("evaluates contextual scores with their baseline and Full DPS requirement", function() + local itemsTab = build.itemsTab + itemsTab:CreateDisplayItemFromRaw([[ +Rarity: RARE +Weighted Sort Helmet +Royal Burgonet +Item Level: 86 +Crafted: true +Prefix: None +Prefix: None +Prefix: None +Suffix: None +Suffix: None +Suffix: None +Quality: 20 +Implicits: 0 +]]) + itemsTab.tradeQuery.statSortSelectionList = { + { stat = "FullDPS", label = "Full DPS", weightMult = 1 }, + } + itemsTab.controls.craftingSorting:SelByValue("WeightedScore", "stat") + + local useFullDPSCalls = { } + build.calcsTab.GetMiscCalculator = function() + return function(params, useFullDPS) + useFullDPSCalls[#useFullDPSCalls + 1] = useFullDPS + return { + modCount = params and params.repItem and #params.repItem.explicitModLines or 0, + } + end + end + + local getValueCalls = 0 + local sawBaseline = false + data.powerStatList.GetValue = function(output, statTable, ownerBuild, calcBase) + getValueCalls = getValueCalls + 1 + sawBaseline = sawBaseline or (calcBase and calcBase.modCount == 0) + assert.are.equal("WeightedScore", statTable.stat) + assert.are.equal(build, ownerBuild) + return output.modCount + end + + local control = itemsTab.controls.displayItemAffix1 + itemsTab:UpdateAffixControl( + control, + itemsTab.displayItem, + "Prefix", + "prefixes", + 1, + { } + ) + + assert.is_true(getValueCalls > 0, "crafted affix sorting must evaluate contextual stats through GetValue") + assert.is_true(sawBaseline, "contextual scoring must receive the item without the candidate affix") + assert.is_true(#useFullDPSCalls > 0) + for _, useFullDPS in ipairs(useFullDPSCalls) do + assert.is_true(useFullDPS) + end + assert.is_not_nil(control.list[2].modList) + local highestScoredMod = itemsTab.displayItem.affixes[control.list[2].modList[1]] + assert.are.equal(2, #highestScoredMod, "the highest contextual score must sort first") + end) +end) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index bc745c074ef..8c1ef772e17 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -2318,6 +2318,7 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, if sortOption.stat and self.controls.craftingSortingLabel.shown() then local calcFunc = self.build.calcsTab:GetMiscCalculator() local slotName = self.displayItem:GetPrimarySlot() + local useFullDPS = data.powerStatList.RequiresFullDPS(sortOption, self.build) local testSubject = new("Item"):Item(self.displayItem:BuildRaw()) local controlPowerCache = powerCache if selAffix and selAffix ~= "None" then @@ -2325,6 +2326,8 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, testSubject:Craft() controlPowerCache = { } end + local calcBase = sortOption.getValue + and calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS) local function pickModifierFromList(modList) -- pick mid tier modifier from a group if #modList == 1 then @@ -2356,9 +2359,11 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, t_insert(testSubject.explicitModLines, modLine) end testSubject:BuildAndParseRaw() - power = data.powerStatList.GetFromOutput( - calcFunc({ repSlotName = slotName, repItem = testSubject }), - sortOption + power = data.powerStatList.GetValue( + calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS), + sortOption, + self.build, + calcBase ) testSubject = new("Item"):Item(originalItem) else @@ -2373,9 +2378,11 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, end testSubject:BuildModList() - power = data.powerStatList.GetFromOutput( - calcFunc({ repSlotName = slotName, repItem = testSubject }), - sortOption + power = data.powerStatList.GetValue( + calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS), + sortOption, + self.build, + calcBase ) for _ = 1, modCount do t_remove(testSubject.explicitModLines, #testSubject.explicitModLines) From f3396f219201b70532e50a012bcf79e7190aa468 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 11:07:41 +0200 Subject: [PATCH 24/37] Add weighted score editing to remaining selectors Keep crafted affix, Compare Power, and Timeless fallback scoring aligned with saved weights. --- spec/System/TestAbyssTimelessJewel_spec.lua | 36 ++++++++++ spec/System/TestWeightedScore_spec.lua | 73 +++++++++++++++++++++ src/Classes/CompareTab.lua | 15 ++++- src/Classes/ItemsTab.lua | 18 ++++- src/Classes/TreeTab.lua | 29 ++++++-- 5 files changed, 163 insertions(+), 8 deletions(-) diff --git a/spec/System/TestAbyssTimelessJewel_spec.lua b/spec/System/TestAbyssTimelessJewel_spec.lua index 1f64037c529..b793e6d470a 100644 --- a/spec/System/TestAbyssTimelessJewel_spec.lua +++ b/spec/System/TestAbyssTimelessJewel_spec.lua @@ -300,6 +300,42 @@ describe("Abyss timeless jewels", function() assert.matches("abyss_special_small_attribute25, 1, 0, 0", build.timelessData.searchListFallback, nil, true) end) + it("exposes weight editing beside the Weighted Score fallback mode", function() + build.timelessData.jewelType = { id = 11 } + build.timelessData.conquerorType = { } + build.timelessData.jewelSocket = { id = 26196 } + build.itemsTab.tradeQuery.statSortSelectionList = { + { stat = "FullDPS", label = "Full DPS", weightMult = 1 }, + } + build.treeTab:FindTimelessJewel() + local control = main.popups[1].controls.fallbackWeightsList + local weightedIndex + local weightedCount = 0 + for index, entry in ipairs(control.list) do + if entry.isWeightedScore then + weightedIndex = index + weightedCount = weightedCount + 1 + end + end + assert.are.equal(1, weightedCount) + assert.is_truthy(weightedIndex) + assert.is_true(control.list[weightedIndex + 1].isAction) + assert.is_true(data.powerStatList.RequiresFullDPS(control.list[weightedIndex], build)) + + local opened = false + build.itemsTab.tradeQuery.SetStatWeights = function() + opened = true + end + control:SetSel(weightedIndex) + for char in ("Edit"):gmatch(".") do + control:OnSearchChar(char) + end + control:SetSel(1) + + assert.is_true(opened) + assert.are.equal("WeightedScore", control:GetSelValue().stat) + end) + it("reads Zorath seed 6564 node and Inquisitor ascendancy changes", function() data.timelessJewelLUTs[11] = parseAbyssJewel(11, zorathExampleData()) local expected = { diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index ff58dff539b..4c305cf7edc 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -464,6 +464,79 @@ describe("WeightedScore — tree integration", function() end) end) +describe("WeightedScore — selector contracts", function() + local function findWeightedScore(list) + local weightedIndex + local weightedCount = 0 + for index, entry in ipairs(list) do + if entry.isWeightedScore then + weightedIndex = index + weightedCount = weightedCount + 1 + end + end + assert.are.equal(1, weightedCount) + assert.is_truthy(weightedIndex) + assert.is_truthy(list[weightedIndex + 1]) + assert.is_true(list[weightedIndex + 1].isAction) + return weightedIndex + end + + before_each(function() + newBuild() + end) + + it("opens weight editing from crafted modifier sorting and restores Weighted Score", function() + local itemsTab = build.itemsTab + itemsTab:CreateDisplayItemFromRaw([[ +Rarity: RARE +Weighted Selector Helmet +Royal Burgonet +Item Level: 86 +Crafted: true +Prefix: None +Prefix: None +Prefix: None +Suffix: None +Suffix: None +Suffix: None +Quality: 20 +Implicits: 0 +]]) + local control = itemsTab.controls.craftingSorting + local weightedIndex = findWeightedScore(control.list) + local opened = false + itemsTab.tradeQuery.SetStatWeights = function(_, _, onSave) + opened = true + onSave() + end + + control:SetSel(weightedIndex) + control:SetSel(weightedIndex + 1) + + assert.is_true(opened) + assert.are.equal("WeightedScore", control:GetSelValue().stat) + end) + + it("opens weight editing from Compare Power and invalidates the selected report", function() + local compareTab = build.compareTab + local control = compareTab.controls.comparePowerStatSelect + local weightedIndex = findWeightedScore(control.list) + local opened = false + build.itemsTab.tradeQuery.SetStatWeights = function(_, _, onSave) + opened = true + onSave() + end + + control:SetSel(weightedIndex) + compareTab.comparePowerDirty = false + control:SetSel(weightedIndex + 1) + + assert.is_true(opened) + assert.are.equal("WeightedScore", control:GetSelValue().stat) + assert.is_true(compareTab.comparePowerDirty) + end) +end) + describe("WeightedScore — crafted affix sorting", function() local originalGetMiscCalculator local originalGetValue diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index d2e09b12b93..c007632a19b 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -978,8 +978,21 @@ function CompareTabClass:InitControls() t_insert(powerStatList, entry) end end + WeightedScore.appendEditWeightsAction(powerStatList, function() + self.controls.comparePowerStatSelect:SelByValue(self.comparePowerStat and self.comparePowerStat.stat, "stat") + local tradeQuery = self.primaryBuild.itemsTab.tradeQuery + if tradeQuery then + tradeQuery:SetStatWeights(nil, function() + if self.comparePowerStat and self.comparePowerStat.isWeightedScore then + self.comparePowerDirty = true + end + end) + end + end) self.controls.comparePowerStatSelect = new("DropDownControl"):DropDownControl(nil, {0, 0, 200, 20}, powerStatList, function(index, value) - if value and value.stat and value ~= self.comparePowerStat then + if value and value.isAction then + value.action() + elseif value and value.stat and value ~= self.comparePowerStat then self.comparePowerStat = value self.comparePowerDirty = true elseif value and not value.stat then diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 8c1ef772e17..7537e9986f3 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -662,6 +662,15 @@ holding Shift will put it in the second.]]) table.insert(sortingOptions, option) end end + local activeCraftingSort = sortingOptions[1] + WeightedScore.appendEditWeightsAction(sortingOptions, function() + self.controls.craftingSorting:SelByValue(activeCraftingSort.stat, "stat") + if self.tradeQuery then + self.tradeQuery:SetStatWeights(nil, function() + self:UpdateAffixControls() + end) + end + end) -- Section: Catalysts self.controls.displayItemSectionCatalyst = new("Control"):Control({"TOPLEFT",self.controls.displayItemSectionQuality,"BOTTOMLEFT"}, {0, 0, 0, function() return (self.controls.displayItemCatalyst:IsShown() or self.controls.displayItemCatalystQualityEdit:IsShown()) and 28 or 0 @@ -731,8 +740,13 @@ holding Shift will put it in the second.]]) -- cluster jewels don't have good comparison support and sorting would be misleading not (self.displayItem.base.type == "Jewel" and self.displayItem.base.subType == "Cluster") end - self.controls.craftingSorting = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function() - self:UpdateAffixControls() + self.controls.craftingSorting = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function(index, value) + if value.isAction then + value.action() + else + activeCraftingSort = value + self:UpdateAffixControls() + end end) -- Section: Affix Selection diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index b06a66af4d7..a1ff06b2d91 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1965,8 +1965,7 @@ function TreeTabClass:FindTimelessJewel() local function generateFallbackWeights(nodes, powerStat) local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) - local useFullDPS = powerStat.stat == "FullDPS" - or (powerStat.isWeightedScore and WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(self.build))) + local useFullDPS = data.powerStatList.RequiresFullDPS(powerStat, self.build) if useFullDPS then calcBase = calcFunc(nil, true) end @@ -2150,14 +2149,34 @@ function TreeTabClass:FindTimelessJewel() stat = stat.stat, transform = stat.transform, getValue = stat.getValue, + requiresFullDPS = stat.requiresFullDPS, + isWeightedScore = stat.isWeightedScore, }) end end - controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index) - timelessData.fallbackWeightMode.idx = index + local activeFallbackWeightIndex = timelessData.fallbackWeightMode.idx or 1 + if not fallbackWeightsList[activeFallbackWeightIndex] then + activeFallbackWeightIndex = 1 + end + local activeFallbackWeight = fallbackWeightsList[activeFallbackWeightIndex] + WeightedScore.appendEditWeightsAction(fallbackWeightsList, function() + controls.fallbackWeightsList:SelByValue(activeFallbackWeight.stat, "stat") + local tradeQuery = self.build.itemsTab.tradeQuery + if tradeQuery then + tradeQuery:SetStatWeights() + end + end) + controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index, value) + if value.isAction then + value.action() + else + activeFallbackWeightIndex = index + activeFallbackWeight = value + timelessData.fallbackWeightMode.idx = index + end end) controls.fallbackWeightsLabel = new("LabelControl"):LabelControl({"RIGHT", controls.fallbackWeightsList, "LEFT"}, {-labelSpacing, 0, 0, labelHeight}, "^7Fallback Weight Mode:") - controls.fallbackWeightsList.selIndex = timelessData.fallbackWeightMode.idx or 1 + controls.fallbackWeightsList.selIndex = activeFallbackWeightIndex controls.fallbackWeightsButton = new("ButtonControl"):ButtonControl({"LEFT", controls.fallbackWeightsList, "RIGHT"}, {5, 0, 66, 18}, "Generate", function() setupFallbackWeights() controls.searchListFallbackButton.label = "^4Fallback Nodes" From 0385cb77a6f2ed85147cdc0a437f518ba1c877bf Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 15 Aug 2026 17:06:48 +0200 Subject: [PATCH 25/37] Adapt weighted score tests to current class syntax --- spec/System/TestItemDBControl_spec.lua | 4 ++-- spec/System/TestNotableDBControl_spec.lua | 2 +- spec/System/TestTradeQuery_spec.lua | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua index 6fa9895ef17..1c56945025b 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -104,7 +104,7 @@ describe("ItemDBControl", function() end, }, } - local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { + local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { list = { invalidItem, betterItem, worseItem }, }, "RARE") control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } @@ -150,7 +150,7 @@ describe("ItemDBControl", function() end, }, } - local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") + local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua index 7a5c31222f1..8bc46165982 100644 --- a/spec/System/TestNotableDBControl_spec.lua +++ b/spec/System/TestNotableDBControl_spec.lua @@ -25,7 +25,7 @@ describe("NotableDBControl", function() end, }, } - local control = new("NotableDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANNOINT") + local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANNOINT") control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 7b3b56f51e3..c176fa22eec 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -188,7 +188,7 @@ describe("TradeQuery", function() main.ClosePopup = function() end local itemsTab = {} - local tradeQuery = new("TradeQuery", itemsTab) + local tradeQuery = new("TradeQuery"):TradeQuery(itemsTab) local ok, errMsg = pcall(function() tradeQuery:SetStatWeights() for _, entry in ipairs(capturedControls.ListControl.list) do @@ -218,7 +218,7 @@ describe("TradeQuery", function() local callbackCount = 0 local ok, errMsg = pcall(function() - local tradeQuery = new("TradeQuery", {}) + local tradeQuery = new("TradeQuery"):TradeQuery({}) tradeQuery:SetStatWeights(nil, function() callbackCount = callbackCount + 1 end) From fcbb83ffdfc83a398795fac0c16380811e83a089 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 17:20:27 +0200 Subject: [PATCH 26/37] Simplify weighted score consumer plumbing Remove redundant type and action flags, then route existing consumers through the shared power-stat accessors while preserving their score contracts. --- spec/System/TestAbyssTimelessJewel_spec.lua | 4 +- spec/System/TestItemDBControl_spec.lua | 15 +++++-- spec/System/TestNotableDBControl_spec.lua | 10 ++++- spec/System/TestWeightedScore_spec.lua | 46 +++++++-------------- src/Classes/CalcsTab.lua | 17 ++------ src/Classes/CompareTab.lua | 36 +++++++--------- src/Classes/ItemDBControl.lua | 41 ++++-------------- src/Classes/ItemsTab.lua | 4 +- src/Classes/NotableDBControl.lua | 27 +++++------- src/Classes/TradeQuery.lua | 2 +- src/Classes/TreeTab.lua | 20 +++------ src/Modules/Data.lua | 9 ++-- src/Modules/WeightedScore.lua | 8 ++-- 13 files changed, 90 insertions(+), 149 deletions(-) diff --git a/spec/System/TestAbyssTimelessJewel_spec.lua b/spec/System/TestAbyssTimelessJewel_spec.lua index b793e6d470a..9e8f19cb5be 100644 --- a/spec/System/TestAbyssTimelessJewel_spec.lua +++ b/spec/System/TestAbyssTimelessJewel_spec.lua @@ -312,14 +312,14 @@ describe("Abyss timeless jewels", function() local weightedIndex local weightedCount = 0 for index, entry in ipairs(control.list) do - if entry.isWeightedScore then + if entry.stat == "WeightedScore" then weightedIndex = index weightedCount = weightedCount + 1 end end assert.are.equal(1, weightedCount) assert.is_truthy(weightedIndex) - assert.is_true(control.list[weightedIndex + 1].isAction) + assert.is_function(control.list[weightedIndex + 1].action) assert.is_true(data.powerStatList.RequiresFullDPS(control.list[weightedIndex], build)) local opened = false diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua index 1c56945025b..94ec9d995cb 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -1,5 +1,12 @@ describe("ItemDBControl", function() local originalGetCursorPos + local function findPowerStat(statName) + for _, stat in ipairs(data.powerStatList) do + if stat.stat == statName then + return stat + end + end + end before_each(function() originalGetCursorPos = GetCursorPos @@ -107,7 +114,7 @@ describe("ItemDBControl", function() local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { list = { invalidItem, betterItem, worseItem }, }, "RARE") - control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortDetail = copyTable(findPowerStat("WeightedScore")) control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() @@ -115,8 +122,8 @@ describe("ItemDBControl", function() assert.are.equal(betterItem, control.list[1]) assert.are.equal(worseItem, control.list[2]) assert.are.equal(invalidItem, control.list[3]) - assert.are.equal(-0.8, betterItem.measuredPower) - assert.are.equal(-1.2, worseItem.measuredPower) + assert.is_true(betterItem.measuredPower < 0) + assert.is_true(worseItem.measuredPower < betterItem.measuredPower) assert.are.equal(-math.huge, invalidItem.measuredPower) assert.are.same({ false, false }, requestedFullDPS) end) @@ -151,7 +158,7 @@ describe("ItemDBControl", function() }, } local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") - control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortDetail = copyTable(findPowerStat("WeightedScore")) control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua index 8bc46165982..7bb933f938c 100644 --- a/spec/System/TestNotableDBControl_spec.lua +++ b/spec/System/TestNotableDBControl_spec.lua @@ -26,12 +26,20 @@ describe("NotableDBControl", function() }, } local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANNOINT") - control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + for _, stat in ipairs(data.powerStatList) do + if stat.stat == "WeightedScore" then + control.sortDetail = copyTable(stat) + break + end + end control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() assert.are.same({ true, true }, requestedFullDPS) assert.are.equal(notable, control.list[1]) + assert.is_true(notable.measuredPower > 0) + assert.are.equal(notable.measuredPower, control.sortMaxPower) + assert.are.equal("^xFF8080Full DPS Notable", control:GetRowValue(1, 1, notable)) end) end) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 4c305cf7edc..5b923121611 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -196,9 +196,6 @@ describe("WeightedScore — TradeQueryGenerator delegation", function() GetTradeStatusOption = function() return "online" end, }) - -- Pass: WeightedRatioOutputs returns the same value as calling - -- WeightedScore.computeRatioScore directly, confirming delegation - -- Fail: divergence would indicate the wrapper has extra logic or a copy-paste it("WeightedRatioOutputs delegates to WeightedScore.computeRatioScore", function() local savedMax = data.misc.maxStatIncrease data.misc.maxStatIncrease = 2 @@ -214,8 +211,6 @@ describe("WeightedScore — TradeQueryGenerator delegation", function() assert.are.equal(direct, delegated) end) - -- Pass: higher-stat candidate ranks above lower-stat candidate - -- Fail: regression in delegation would silently return 0 for all, making order random it("higher-stat candidate ranks above lower-stat candidate", function() local base = { TotalDPS = 1000 } local high = { TotalDPS = 1500 } @@ -250,12 +245,9 @@ describe("WeightedScore — tree integration", function() until not build.calcsTab.powerBuilder or iter >= maxIter end - -- Pass: WeightedScore entry is registered in the shared power stat list - -- Fail: missing registration would mean the mode never appears in the UI - it("WeightedScore entry exists in data.powerStatList with isWeightedScore flag", function() + it("registers WeightedScore as the final shared power stat", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) - assert.is_true(stat.isWeightedScore) assert.are.equal("WeightedScore", data.powerStatList[#data.powerStatList].stat) end) @@ -263,8 +255,6 @@ describe("WeightedScore — tree integration", function() assert.is_nil(findStat("MinionWeightedScore")) end) - -- Pass: power builder runs to completion without Lua error - -- Fail: a crash in CalculatePowerStat's isWeightedScore branch it("power builder completes without error using WeightedScore stat", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) @@ -272,8 +262,6 @@ describe("WeightedScore — tree integration", function() assert.is_true(build.calcsTab.powerBuilderInitialized) end) - -- Pass: powerMax is initialized and singleStat is non-negative - -- Fail: negative singleStat would break heatmap colour scaling it("powerMax.singleStat is non-negative after WeightedScore build", function() drainPowerBuild(findStat("WeightedScore")) assert.is_not_nil(build.calcsTab.powerMax) @@ -321,9 +309,7 @@ describe("WeightedScore — tree integration", function() end end) - -- Pass: getValue returns a positive score when the new output is better than base - -- Fail: reading output["WeightedScore"] (non-existent field) would return 0, giving - -- weight1 = (0/1 - 1)*100 = -100 for every fallback node regardless of actual impact + -- Fallback weights must evaluate the configured stats rather than a synthetic output key. it("getValue on WeightedScore entry returns positive score for better output", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) @@ -335,7 +321,7 @@ describe("WeightedScore — tree integration", function() assert.is_true(betterScore > baseScore) end) - it("power stat helpers evaluate WeightedScore with its baseline and Full DPS requirement", function() + it("power stat helpers keep WeightedScore deltas and baselines on the same scale", function() local weightedScore = findStat("WeightedScore") local fullDPS = findStat("FullDPS") local life = findStat("Life") @@ -345,10 +331,13 @@ describe("WeightedScore — tree integration", function() assert.is_true(data.powerStatList.RequiresFullDPS(weightedScore, build)) assert.is_true(data.powerStatList.RequiresFullDPS(fullDPS, build)) assert.is_false(data.powerStatList.RequiresFullDPS(life, build)) - assert.is_true( - data.powerStatList.GetValue(betterOutput, weightedScore, build, baseOutput) - > data.powerStatList.GetValue(baseOutput, weightedScore, build, baseOutput) - ) + local baseValue = data.powerStatList.GetValue(baseOutput, weightedScore, build, baseOutput) + local betterValue = data.powerStatList.GetValue(betterOutput, weightedScore, build, baseOutput) + local delta = build.calcsTab:CalculatePowerStat(weightedScore, betterOutput, baseOutput) + assert.is_true(math.abs(baseValue - 1500) < 0.0001) + assert.is_true(math.abs(betterValue - 1700) < 0.0001) + assert.is_true(math.abs(delta - 200) < 0.0001) + assert.is_true(math.abs(delta / baseValue - 2 / 15) < 0.0001) assert.are.equal(123, data.powerStatList.GetValue({ Life = 123 }, life, build, baseOutput)) end) @@ -377,10 +366,6 @@ describe("WeightedScore — tree integration", function() assert.is_true(score > 0) end) - -- Pass: getValue returns a non-zero base score (build has some meaningful output) - -- Fail: if getValue silently returned 0 for base, generateFallbackWeights would - -- set baseValue=1 and all weights would be computed against 1 instead of the - -- real build score, producing incorrect -100 values for all neutral nodes it("getValue on WeightedScore entry returns non-zero score for current build output", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) @@ -406,16 +391,15 @@ describe("WeightedScore — tree integration", function() it("appendEditWeightsAction appends an action after WeightedScore", function() local list = { { label = "Sort by Name", sortMode = "name" }, - { label = "Sort by Weighted Score", sortMode = "WeightedScore", isWeightedScore = true }, + { label = "Sort by Weighted Score", sortMode = "WeightedScore", stat = "WeightedScore" }, } local opened = false WeightedScore.appendEditWeightsAction(list, function() opened = true end) assert.are.equal(3, #list) local entry = list[3] - assert.is_true(entry.isAction) assert.is_function(entry.action) assert.is_string(entry.label) - assert.is_true(list[2].isWeightedScore) + assert.are.equal("WeightedScore", list[2].stat) entry.action() assert.is_true(opened, "calling entry.action must invoke the openEditor callback") end) @@ -423,7 +407,7 @@ describe("WeightedScore — tree integration", function() it("createSortHandler restores the metric and invalidates cached candidate scores after editing weights", function() local list = { { label = "Default", stat = nil }, - { label = "Weighted Score", stat = "WeightedScore", isWeightedScore = true }, + { label = "Weighted Score", stat = "WeightedScore" }, } local candidates = { { label = "Damage", scores = { damage = 2, defence = 1 } }, @@ -469,7 +453,7 @@ describe("WeightedScore — selector contracts", function() local weightedIndex local weightedCount = 0 for index, entry in ipairs(list) do - if entry.isWeightedScore then + if entry.stat == "WeightedScore" then weightedIndex = index weightedCount = weightedCount + 1 end @@ -477,7 +461,7 @@ describe("WeightedScore — selector contracts", function() assert.are.equal(1, weightedCount) assert.is_truthy(weightedIndex) assert.is_truthy(list[weightedIndex + 1]) - assert.is_true(list[weightedIndex + 1].isAction) + assert.is_function(list[weightedIndex + 1].action) return weightedIndex end diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 5afbb52d05e..f764a268236 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -8,7 +8,6 @@ local ipairs = ipairs local t_insert = table.insert local m_max = math.max local m_floor = math.floor -local WeightedScore = LoadModule("Modules/WeightedScore") local buffModeDropList = { { label = "Unbuffed", buffMode = "UNBUFFED" }, @@ -496,11 +495,7 @@ end -- Estimate the offensive and defensive power of all unallocated nodes function CalcsTabClass:PowerBuilder() -- local timer_start = GetTime() - local useFullDPS = self.powerStat and ( - self.powerStat.stat == "FullDPS" - or (self.powerStat.isWeightedScore - and WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(self.build))) - ) + local useFullDPS = self.powerStat and data.powerStatList.RequiresFullDPS(self.powerStat, self.build) local calcFunc, calcBase = self:GetMiscCalculator() local cache = { } local distanceMap = { } @@ -745,14 +740,8 @@ function CalcsTabClass:PowerBuilder() end function CalcsTabClass:CalculatePowerStat(selection, original, modified) - if selection.isWeightedScore then - local weights = WeightedScore.getWeights(self.build) - local nodeScore = WeightedScore.computeRatioScore(modified, original, weights) - local baseScore = WeightedScore.computeRatioScore(modified, modified, weights) - return (nodeScore - baseScore) * 1000 - end - local originalValue = data.powerStatList.GetFromOutput(original, selection) - local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) + local originalValue = data.powerStatList.GetValue(original, selection, self.build, modified) + local modifiedValue = data.powerStatList.GetValue(modified, selection, self.build, modified) return originalValue - modifiedValue end diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index c007632a19b..a7329c941c9 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -983,14 +983,14 @@ function CompareTabClass:InitControls() local tradeQuery = self.primaryBuild.itemsTab.tradeQuery if tradeQuery then tradeQuery:SetStatWeights(nil, function() - if self.comparePowerStat and self.comparePowerStat.isWeightedScore then + if self.comparePowerStat and self.comparePowerStat.stat == "WeightedScore" then self.comparePowerDirty = true end end) end end) self.controls.comparePowerStatSelect = new("DropDownControl"):DropDownControl(nil, {0, 0, 200, 20}, powerStatList, function(index, value) - if value and value.isAction then + if value and value.action then value.action() elseif value and value.stat and value ~= self.comparePowerStat then self.comparePowerStat = value @@ -2529,15 +2529,17 @@ end -- Coroutine: calculate power of compared build elements against primary build function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories) local results = {} - local weightedScoreWeights = powerStat.isWeightedScore and WeightedScore.getWeights(self.primaryBuild) - local useFullDPS = powerStat.stat == "FullDPS" - or (powerStat.isWeightedScore and WeightedScore.weightsNeedFullDPS(weightedScoreWeights)) + local useFullDPS = data.powerStatList.RequiresFullDPS(powerStat, self.primaryBuild) + local function getPowerCalculator() + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + if useFullDPS then + calcBase = calcFunc(nil, true) + end + return calcFunc, calcBase + end -- Get calculator for primary build - local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) - if useFullDPS then - calcBase = calcFunc(nil, true) - end + local calcFunc, calcBase = getPowerCalculator() -- Find display stat for formatting local displayStat = nil @@ -2640,8 +2642,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- Get baseline stat value for percentage calculation - local baseStatValue = powerStat.getValue and powerStat.getValue(calcBase, self.primaryBuild, calcBase) - or data.powerStatList.GetFromOutput(calcBase, powerStat) + local baseStatValue = data.powerStatList.GetValue(calcBase, powerStat, self.primaryBuild, calcBase) -- Helper to format an impact value and compute percentage local function formatImpact(impact) @@ -2937,10 +2938,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories self.primaryBuild.buildFlag = true -- Get a fresh calculator with the added group (pcall to guarantee cleanup) - local ok, gemCalcFunc, gemCalcBase = pcall(function() - local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) - return calcFunc, useFullDPS and calcFunc(nil, true) or calcBase - end) + local ok, gemCalcFunc, gemCalcBase = pcall(getPowerCalculator) -- Always remove the temporarily added group t_remove(pGroups) @@ -3021,10 +3019,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories t_insert(pMainGroup.gemList, tempGem) self.primaryBuild.buildFlag = true - local ok, sgCalcFunc, sgCalcBase = pcall(function() - local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) - return calcFunc, useFullDPS and calcFunc(nil, true) or calcBase - end) + local ok, sgCalcFunc, sgCalcBase = pcall(getPowerCalculator) -- Always remove the temporarily added gem t_remove(pMainGroup.gemList) @@ -3092,8 +3087,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local ok, cfgCalcFunc, cfgCalcBase = pcall(function() self.primaryBuild.configTab:BuildModList() self.primaryBuild.buildFlag = true - local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) - return calcFunc, useFullDPS and calcFunc(nil, true) or calcBase + return getPowerCalculator() end) -- Always restore original value diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 5e7f1ca2212..caef8f09705 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -49,7 +49,7 @@ function ItemDBClass:ItemDBControl(anchor, rect, itemsTab, db, dbType) end) if dbType == "UNIQUE" then self.controls.sort = new("DropDownControl"):DropDownControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, baseY + 20, 179, 18}, self.sortDropList, function(index, value) - if value.isAction then + if value.action then value.action() self.controls.sort:SelByValue(self.sortMode, "sortMode") else @@ -227,14 +227,10 @@ function ItemDBClass:BuildSortOrder() wipeTable(self.sortDropList) for id, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems then - t_insert(self.sortDropList, { - label="Sort by "..stat.label, - sortMode=stat.itemField or stat.stat, - itemField=stat.itemField, - stat=stat.stat, - transform=stat.transform, - isWeightedScore=stat.isWeightedScore, - }) + local sortEntry = copyTable(stat) + sortEntry.label = "Sort by " .. stat.label + sortEntry.sortMode = stat.itemField or stat.stat + t_insert(self.sortDropList, sortEntry) end end WeightedScore.appendEditWeightsAction(self.sortDropList, function() @@ -264,37 +260,16 @@ function ItemDBClass:ListBuilder() end end - if self.sortDetail and self.sortDetail.isWeightedScore then + if self.sortDetail and self.sortDetail.stat then -- stat-based + local useFullDPS = data.powerStatList.RequiresFullDPS(self.sortDetail, self.itemsTab.build) local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) - local weights = WeightedScore.getWeights(self.itemsTab.build) - local useFullDPS = WeightedScore.weightsNeedFullDPS(weights) for itemIndex, item in ipairs(list) do item.measuredPower = -math.huge for slotName, slot in pairs(self.itemsTab.slots) do if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }, useFullDPS) - local score = WeightedScore.computeRatioScore(calcBase, output, weights) - item.measuredPower = m_max(item.measuredPower, score) - end - end - local now = GetTime() - if now - start > 50 then - self.defaultText = "^7Sorting... ("..m_floor(itemIndex/#list*100).."%)" - coroutine.yield() - start = now - end - end - elseif self.sortDetail and self.sortDetail.stat then -- stat-based - local useFullDPS = self.sortDetail.stat == "FullDPS" - local start = GetTime() - local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) - for itemIndex, item in ipairs(list) do - item.measuredPower = -math.huge - for slotName, slot in pairs(self.itemsTab.slots) do - if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then - local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }, useFullDPS) - local measuredPower = data.powerStatList.GetFromOutput(output, self.sortDetail) + local measuredPower = data.powerStatList.GetValue(output, self.sortDetail, self.itemsTab.build, calcBase) item.measuredPower = m_max(item.measuredPower, measuredPower) end end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 7537e9986f3..33f221f9431 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -78,7 +78,7 @@ local function buildModSortList() local sortStats = { } for _, entry in ipairs(data.powerStatList) do if entry.stat and not entry.ignoreForNodes then - t_insert(sortList, { label = entry.label, stat = entry.stat, isWeightedScore = entry.isWeightedScore }) + t_insert(sortList, { label = entry.label, stat = entry.stat }) sortStats[entry.stat] = entry end end @@ -741,7 +741,7 @@ holding Shift will put it in the second.]]) not (self.displayItem.base.type == "Jewel" and self.displayItem.base.subType == "Cluster") end self.controls.craftingSorting = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function(index, value) - if value.isAction then + if value.action then value.action() else activeCraftingSort = value diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index de8c9098261..824a836d928 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -37,7 +37,7 @@ function NotableDBClass:NotableDBControl(anchor, rect, itemsTab, db, dbType) self.sortOrder = { } self.sortMode = "NAME" self.controls.sort = new("DropDownControl"):DropDownControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -22, 360, 18}, self.sortDropList, function(index, value) - if value.isAction then + if value.action then value.action() self.controls.sort:SelByValue(self.sortMode, "sortMode") else @@ -99,14 +99,10 @@ function NotableDBClass:BuildSortOrder() wipeTable(self.sortDropList) for id, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems then - t_insert(self.sortDropList, { - label="Sort by "..stat.label, - sortMode=stat.itemField or stat.stat, - itemField=stat.itemField, - stat=stat.stat, - transform=stat.transform, - isWeightedScore=stat.isWeightedScore, - }) + local sortEntry = copyTable(stat) + sortEntry.label = "Sort by " .. stat.label + sortEntry.sortMode = stat.itemField or stat.stat + t_insert(self.sortDropList, sortEntry) end end WeightedScore.appendEditWeightsAction(self.sortDropList, function() @@ -128,6 +124,9 @@ function NotableDBClass:BuildSortOrder() end function NotableDBClass:CalculatePowerStat(selection, original, modified) + if selection.getValue then + return data.powerStatList.GetValue(original, selection, self.itemsTab.build, modified) + end local originalValue = data.powerStatList.GetFromOutput(original, selection) local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) return originalValue - modifiedValue @@ -147,20 +146,14 @@ function NotableDBClass:ListBuilder() local start = GetTime() local calcFunc = self.itemsTab.build.calcsTab:GetMiscCalculator() local itemType = self.itemsTab.displayItem.base.type - local weights = self.sortDetail.isWeightedScore and WeightedScore.getWeights(self.itemsTab.build) - local useFullDPS = self.sortDetail.stat == "FullDPS" - or (self.sortDetail.isWeightedScore and WeightedScore.weightsNeedFullDPS(weights)) + local useFullDPS = data.powerStatList.RequiresFullDPS(self.sortDetail, self.itemsTab.build) local calcBase = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(nil) }, useFullDPS) self.sortMaxPower = 0 for nodeIndex, node in ipairs(list) do node.measuredPower = 0 if node.modKey ~= "" then local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }, useFullDPS) - if self.sortDetail.isWeightedScore then - node.measuredPower = WeightedScore.computeRatioScore(calcBase, output, weights) - else - node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) - end + node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) if node.measuredPower == m_huge then t_insert(infinites, node) else diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 240702ebf72..41f6583ebb9 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -709,7 +709,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList, onSave) { -410, 45, 400, listHeight }, statList, sliderController) for _, stat in ipairs(data.powerStatList) do - if not stat.ignoreForItems and stat.label ~= "Name" and not stat.isWeightedScore then + if not stat.ignoreForItems and stat.label ~= "Name" and stat.stat ~= "WeightedScore" then t_insert(statList, { label = "0 : "..stat.label, stat = { diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index a1ff06b2d91..f4e760a81f4 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -255,7 +255,7 @@ function TreeTabClass:TreeTab(build) -- Control for selecting the power stat to sort by (Defense, DPS, etc) self.controls.treeHeatMapStatSelect = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT" }, { 8, 0, 150, 20 }, nil, function(index, value) - if value.isAction then + if value.action then value.action() if self.build.calcsTab.powerStat then self.controls.treeHeatMapStatSelect:SelByValue(self.build.calcsTab.powerStat.stat, "stat") @@ -1971,10 +1971,7 @@ function TreeTabClass:FindTimelessJewel() end local newList = { } local function getStatValue(output) - if powerStat.getValue then - return powerStat.getValue(output, self.build, calcBase) - end - return data.powerStatList.GetFromOutput(output, powerStat) + return data.powerStatList.GetValue(output, powerStat, self.build, calcBase) end local basePower = getStatValue(calcBase) for _, newNode in ipairs(nodes) do @@ -2144,14 +2141,9 @@ function TreeTabClass:FindTimelessJewel() local fallbackWeightsList = { } for _, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems and stat.label ~= "Name" then - t_insert(fallbackWeightsList, { - label = "Sort by " .. stat.label, - stat = stat.stat, - transform = stat.transform, - getValue = stat.getValue, - requiresFullDPS = stat.requiresFullDPS, - isWeightedScore = stat.isWeightedScore, - }) + local fallbackWeight = copyTable(stat) + fallbackWeight.label = "Sort by " .. stat.label + t_insert(fallbackWeightsList, fallbackWeight) end end local activeFallbackWeightIndex = timelessData.fallbackWeightMode.idx or 1 @@ -2167,7 +2159,7 @@ function TreeTabClass:FindTimelessJewel() end end) controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index, value) - if value.isAction then + if value.action then value.action() else activeFallbackWeightIndex = index diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 9ad2f8534b0..b7c2bd1c248 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -125,6 +125,8 @@ end ---@field ignoreForItems? boolean ---@field reverseSort? boolean ---@field itemField string? +---@field requiresFullDPS? boolean|fun(build?: table): boolean +---@field getValue? fun(output: any, build?: table, calcBase?: table): number ---@type PowerStat[] data.powerStatList = { @@ -213,7 +215,7 @@ function data.powerStatList.GetFromOutput(output, statTable, skipTransform) end ---@param output any Calc output ----@param statTable StatTable Table with stats as in data.powerStatList +---@param statTable PowerStat Table with stats as in data.powerStatList ---@param build? table Build that owns the candidate calculation ---@param calcBase? table Output of the baseline calculation ---@return number @@ -224,7 +226,7 @@ function data.powerStatList.GetValue(output, statTable, build, calcBase) return data.powerStatList.GetFromOutput(output, statTable) end ----@param statTable StatTable Table with stats as in data.powerStatList +---@param statTable PowerStat Table with stats as in data.powerStatList ---@param build? table Build that owns the candidate calculation ---@return boolean function data.powerStatList.RequiresFullDPS(statTable, build) @@ -247,7 +249,7 @@ local minionNonApplicableStats = { } for i = 1, #data.powerStatList do local statEntry = data.powerStatList[i] - if (not statEntry.stat) or statEntry.isWeightedScore or statEntry.stat:match("DPS") or minionNonApplicableStats[statEntry.stat] then + if (not statEntry.stat) or statEntry.stat == "WeightedScore" or statEntry.stat:match("DPS") or minionNonApplicableStats[statEntry.stat] then goto statContinue end local minionStat = copyTable(statEntry) @@ -259,7 +261,6 @@ end t_insert(data.powerStatList, { stat="WeightedScore", label="Weighted Score", - isWeightedScore=true, requiresFullDPS=function(build) return WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(build)) end, diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index 35a41f76cf6..b5793c6bcc2 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -1,8 +1,7 @@ -- Path of Building -- -- Module: Weighted Score --- Shared weighted stat score computation and weight management. --- Used by Trade Query, Unique Item DB, Gem Upgrade Report, and Tree heatmap. +-- Shared weighted stat score computation and weight management for stat-based ranking. -- local WeightedScore = {} @@ -82,10 +81,9 @@ end -- score remains the final metric while its configuration stays adjacent. function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) for _, entry in ipairs(sortDropList) do - if entry.isWeightedScore then + if entry.stat == "WeightedScore" then table.insert(sortDropList, { label = colorCodes.TIP .. "Edit Weights...", - isAction = true, action = openEditor, }) return @@ -103,7 +101,7 @@ function WeightedScore.createSortHandler(sortDropList, controls, openEditor, app end) end) return function(index, value) - if value.isAction then + if value.action then value.action() else activeSort = value From e8447b61a5adcf3a1761b0f1151525a704b480b3 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 17:49:17 +0200 Subject: [PATCH 27/37] Clarify weighted score calculation semantics Name the weighted ratio accumulator by its actual result and document why the synthetic power-stat value shares the trade-query scale across consumers. --- src/Modules/Data.lua | 2 ++ src/Modules/WeightedScore.lua | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index b7c2bd1c248..c5eac22074b 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -271,6 +271,8 @@ t_insert(data.powerStatList, { local _, cachedBuildBase = build.calcsTab:GetMiscCalculator() buildBase = cachedBuildBase end + -- Keep this synthetic stat on the trade-query weight scale. Calcs and Compare + -- subtract candidate and baseline values; other consumers rank or normalize it. return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 end, }) diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index b5793c6bcc2..f0f76533c9c 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -45,8 +45,8 @@ end -- Higher score means the candidate is better. -- Missing or zero stats are handled safely (no crash, no infinite values). function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) - local meanStatDiff = 0.0 - local function ratioModSums(...) + local weightedScore = 0.0 + local function computeStatSumRatio(...) local baseModSum = 0 local newModSum = 0 for _, statTable in ipairs({ ... }) do @@ -65,16 +65,16 @@ function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) local modSumRatio if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then -- FullDPS fallback: use combined DPS components when FullDPS is not directly available - modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) + modSumRatio = computeStatSumRatio({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) else - modSumRatio = ratioModSums(statTable) + modSumRatio = computeStatSumRatio(statTable) end if statTable.transform then modSumRatio = statTable.transform(modSumRatio) end - meanStatDiff = meanStatDiff + modSumRatio * statTable.weightMult + weightedScore = weightedScore + modSumRatio * statTable.weightMult end - return meanStatDiff + return weightedScore end -- Append a contextual "Edit Weights..." action after WeightedScore so the From 42f5eab7c9c6847ae14829f1bde101f339ac6912 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 19:32:44 +0200 Subject: [PATCH 28/37] Centralize weighted score editor routing Keep the Trade Query lookup in the shared WeightedScore module while preserving each consumer's local invalidation behavior. --- spec/System/TestWeightedScore_spec.lua | 12 ++++++++++-- src/Classes/CompareTab.lua | 13 +++++-------- src/Classes/ItemDBControl.lua | 5 +---- src/Classes/ItemsTab.lua | 26 ++++++-------------------- src/Classes/NotableDBControl.lua | 5 +---- src/Classes/TreeTab.lua | 10 ++-------- src/Modules/WeightedScore.lua | 11 +++++++++-- 7 files changed, 34 insertions(+), 48 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 5b923121611..59c9ac20eb4 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -376,6 +376,13 @@ describe("WeightedScore — tree integration", function() end) -- appendEditWeightsAction ----------------------------------------------- + local function buildWithWeightEditor(openEditor) + return { itemsTab = { tradeQuery = { + SetStatWeights = function(_, previousSelection, onSave) + openEditor(previousSelection, onSave) + end, + } } } + end it("appendEditWeightsAction is a no-op when the list has no WeightedScore entry", function() local list = { @@ -435,10 +442,11 @@ describe("WeightedScore — tree integration", function() candidate.sortValues = nil end end - local handler = WeightedScore.createSortHandler(list, controls, function(onSave) + local build = buildWithWeightEditor(function(_, onSave) weight = "defence" onSave() - end, applySort, clearSortValues) + end) + local handler = WeightedScore.createSortHandler(list, controls, build, applySort, clearSortValues) handler(2, list[2]) assert.are.equal("Damage", candidates[1].label) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index a7329c941c9..0c5755d92f5 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -980,14 +980,11 @@ function CompareTabClass:InitControls() end WeightedScore.appendEditWeightsAction(powerStatList, function() self.controls.comparePowerStatSelect:SelByValue(self.comparePowerStat and self.comparePowerStat.stat, "stat") - local tradeQuery = self.primaryBuild.itemsTab.tradeQuery - if tradeQuery then - tradeQuery:SetStatWeights(nil, function() - if self.comparePowerStat and self.comparePowerStat.stat == "WeightedScore" then - self.comparePowerDirty = true - end - end) - end + WeightedScore.editWeights(self.primaryBuild, function() + if self.comparePowerStat and self.comparePowerStat.stat == "WeightedScore" then + self.comparePowerDirty = true + end + end) end) self.controls.comparePowerStatSelect = new("DropDownControl"):DropDownControl(nil, {0, 0, 200, 20}, powerStatList, function(index, value) if value and value.action then diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index caef8f09705..fdb1c03657f 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -234,10 +234,7 @@ function ItemDBClass:BuildSortOrder() end end WeightedScore.appendEditWeightsAction(self.sortDropList, function() - local tq = self.itemsTab.tradeQuery - if tq then - tq:SetStatWeights(nil, function() self.listBuildFlag = true end) - end + WeightedScore.editWeights(self.itemsTab.build, function() self.listBuildFlag = true end) end) wipeTable(self.sortOrder) if self.controls.sort then diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 33f221f9431..e12d9d30d98 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -665,11 +665,9 @@ holding Shift will put it in the second.]]) local activeCraftingSort = sortingOptions[1] WeightedScore.appendEditWeightsAction(sortingOptions, function() self.controls.craftingSorting:SelByValue(activeCraftingSort.stat, "stat") - if self.tradeQuery then - self.tradeQuery:SetStatWeights(nil, function() - self:UpdateAffixControls() - end) - end + WeightedScore.editWeights(self.build, function() + self:UpdateAffixControls() + end) end) -- Section: Catalysts self.controls.displayItemSectionCatalyst = new("Control"):Control({"TOPLEFT",self.controls.displayItemSectionQuality,"BOTTOMLEFT"}, {0, 0, 0, function() @@ -2984,11 +2982,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end) controls.sortLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {350, 45, 0, 16}, "^7Sort by:") controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 45, 240, 18}, sortList, - WeightedScore.createSortHandler(sortList, controls, function(onSave) - if self.tradeQuery then - self.tradeQuery:SetStatWeights(nil, onSave) - end - end, applySort, clearSortValues)) + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) controls.enchantmentLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {95, 70, 0, 16}, "^7Enchantment:") controls.enchantment = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {100, 70, 495, 18}, enchantmentList) controls.enchantment.tooltipFunc = function(tooltip, mode, index) @@ -3500,11 +3494,7 @@ function ItemsTabClass:CorruptDisplayItem() controls.source.enabled = #sourceList > 1 controls.sortLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {350, 20, 0, 16}, "^7Sort by:") controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, - WeightedScore.createSortHandler(sortList, controls, function(onSave) - if self.tradeQuery then - self.tradeQuery:SetStatWeights(nil, onSave) - end - end, applySort, clearSortValues)) + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) local implicitRowSize = 20 local implicitYPos = 35 controls.implicitCannotBeChangedLabel = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 20, implicitYPos + implicitRowSize, 0, 20 }, "^7This Items Implicits Cannot Be Changed") @@ -3885,11 +3875,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, - WeightedScore.createSortHandler(sortList, controls, function(onSave) - if self.tradeQuery then - self.tradeQuery:SetStatWeights(nil, onSave) - end - end, applySort, clearSortValues)) + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) controls.sort.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index 824a836d928..e419cc0f8d8 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -106,10 +106,7 @@ function NotableDBClass:BuildSortOrder() end end WeightedScore.appendEditWeightsAction(self.sortDropList, function() - local tq = self.itemsTab.tradeQuery - if tq then - tq:SetStatWeights(nil, function() self.listBuildFlag = true end) - end + WeightedScore.editWeights(self.itemsTab.build, function() self.listBuildFlag = true end) end) wipeTable(self.sortOrder) if self.controls.sort then diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index f4e760a81f4..a900a95e661 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -276,10 +276,7 @@ function TreeTabClass:TreeTab(build) end end WeightedScore.appendEditWeightsAction(self.powerStatList, function() - local tq = self.build.itemsTab.tradeQuery - if tq then - tq:SetStatWeights(nil, function() self:SetPowerCalc(self.build.calcsTab.powerStat) end) - end + WeightedScore.editWeights(self.build, function() self:SetPowerCalc(self.build.calcsTab.powerStat) end) end) -- Show/Hide Power Report Button @@ -2153,10 +2150,7 @@ function TreeTabClass:FindTimelessJewel() local activeFallbackWeight = fallbackWeightsList[activeFallbackWeightIndex] WeightedScore.appendEditWeightsAction(fallbackWeightsList, function() controls.fallbackWeightsList:SelByValue(activeFallbackWeight.stat, "stat") - local tradeQuery = self.build.itemsTab.tradeQuery - if tradeQuery then - tradeQuery:SetStatWeights() - end + WeightedScore.editWeights(self.build) end) controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index, value) if value.action then diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index f0f76533c9c..ebd8e162841 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -24,6 +24,13 @@ function WeightedScore.getWeights(build) return WeightedScore.defaultWeights() end +function WeightedScore.editWeights(build, onSave) + local tradeQuery = build and build.itemsTab and build.itemsTab.tradeQuery + if tradeQuery then + tradeQuery:SetStatWeights(nil, onSave) + end +end + -- Returns true when any active weight targets FullDPS, so callers can route -- through the FullDPS-aware calculation path. function WeightedScore.weightsNeedFullDPS(weights) @@ -91,11 +98,11 @@ function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) end end -function WeightedScore.createSortHandler(sortDropList, controls, openEditor, applySort, clearSortValues) +function WeightedScore.createSortHandler(sortDropList, controls, build, applySort, clearSortValues) local activeSort = sortDropList[1] WeightedScore.appendEditWeightsAction(sortDropList, function() controls.sort:SelByValue(activeSort.stat, "stat") - openEditor(function() + WeightedScore.editWeights(build, function() clearSortValues() applySort(activeSort.stat, true) end) From 995240a652a9cb7dd1ee7390f25f6684eb4441dc Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 20:14:52 +0200 Subject: [PATCH 29/37] Compact weighted score test contracts Preserve all 39 weighted-score scenarios while grouping related cases into 19 labelled contracts and removing a redundant Power Builder run. --- spec/System/TestWeightedScore_spec.lua | 247 +++++++++---------------- 1 file changed, 90 insertions(+), 157 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 59c9ac20eb4..8a9311a934e 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -23,109 +23,75 @@ describe("WeightedScore module", function() -- getWeights --------------------------------------------------------------- - it("getWeights returns defaults when build is nil", function() - local weights = WeightedScore.getWeights(nil) - assert.are.same(WeightedScore.defaultWeights(), weights) - end) - - it("getWeights returns defaults when statSortSelectionList is empty", function() - local mockBuild = { - itemsTab = { - tradeQuery = { statSortSelectionList = {} } - } - } - local weights = WeightedScore.getWeights(mockBuild) - assert.are.same(WeightedScore.defaultWeights(), weights) - end) - - it("getWeights returns custom weights when statSortSelectionList is populated", function() + it("getWeights selects defaults or configured weights", function() local custom = { { stat = "TotalDPS", label = "DPS", weightMult = 2.0 } } - local mockBuild = { - itemsTab = { - tradeQuery = { statSortSelectionList = custom } - } + local cases = { + { label = "nil build", expected = WeightedScore.defaultWeights() }, + { + label = "empty selection", + build = { itemsTab = { tradeQuery = { statSortSelectionList = {} } } }, + expected = WeightedScore.defaultWeights(), + }, + { + label = "configured selection", + build = { itemsTab = { tradeQuery = { statSortSelectionList = custom } } }, + expected = custom, + }, } - local weights = WeightedScore.getWeights(mockBuild) - assert.are.equal(1, #weights) - assert.are.equal("TotalDPS", weights[1].stat) - assert.are.equal(2.0, weights[1].weightMult) - end) - - -- computeRatioScore: basic ranking ----------------------------------------- - it("neutral candidate (identical outputs) scores 1.0 with single unit weight", function() - local base = { TotalDPS = 1000 } - local new = { TotalDPS = 1000 } - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - assert.are.equal(1.0, WeightedScore.computeRatioScore(base, new, weights)) - end) - - it("better candidate scores higher than neutral", function() - local base = { TotalDPS = 1000 } - local better = { TotalDPS = 1500 } - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - local score = WeightedScore.computeRatioScore(base, better, weights) - assert.is_true(score > 1.0) - assert.are.equal(1.5, score) + for _, case in ipairs(cases) do + assert.are.same(case.expected, WeightedScore.getWeights(case.build), case.label) + end end) - it("worse candidate scores lower than neutral", function() - local base = { TotalDPS = 1000 } - local worse = { TotalDPS = 500 } - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - local score = WeightedScore.computeRatioScore(base, worse, weights) - assert.is_true(score < 1.0) - assert.are.equal(0.5, score) - end) + -- computeRatioScore: basic ranking ----------------------------------------- - it("combines multiple stats with their individual weight multipliers", function() - local base = { TotalDPS = 100, TotalEHP = 200 } - local candidate = { TotalDPS = 150, TotalEHP = 250 } - local weights = { - { stat = "TotalDPS", weightMult = 1.5 }, - { stat = "TotalEHP", weightMult = 0.25 }, + it("scores ordinary ratios and weight combinations", function() + local unitWeight = { { stat = "TotalDPS", weightMult = 1.0 } } + local cases = { + { label = "neutral", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1000 }, weights = unitWeight, expected = 1.0 }, + { label = "better", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1500 }, weights = unitWeight, expected = 1.5 }, + { label = "worse", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 500 }, weights = unitWeight, expected = 0.5 }, + { + label = "multiple weighted stats", + base = { TotalDPS = 100, TotalEHP = 200 }, + candidate = { TotalDPS = 150, TotalEHP = 250 }, + weights = { + { stat = "TotalDPS", weightMult = 1.5 }, + { stat = "TotalEHP", weightMult = 0.25 }, + }, + expected = 2.5625, + }, + { label = "empty weights", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 5000 }, weights = {}, expected = 0.0 }, } - -- (150 / 100) * 1.5 + (250 / 200) * 0.25 = 2.5625 - assert.are.equal(2.5625, WeightedScore.computeRatioScore(base, candidate, weights)) - end) - - it("empty weights always scores 0", function() - local base = { TotalDPS = 1000 } - local new = { TotalDPS = 5000 } - assert.are.equal(0.0, WeightedScore.computeRatioScore(base, new, {})) + for _, case in ipairs(cases) do + assert.are.equal( + case.expected, + WeightedScore.computeRatioScore(case.base, case.candidate, case.weights), + case.label + ) + end end) -- computeRatioScore: edge cases -------------------------------------------- - it("infinite base stat contributes 0 (no crash)", function() - local base = { TotalDPS = math.huge } - local new = { TotalDPS = 1000 } + it("handles infinite, zero, and missing ratio inputs safely", function() local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - assert.are.equal(0.0, WeightedScore.computeRatioScore(base, new, weights)) - end) - - it("infinite new stat is capped at maxStatIncrease", function() - local base = { TotalDPS = 1000 } - local new = { TotalDPS = math.huge } - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - -- maxStatIncrease == 2 (set in before_each) - assert.are.equal(2.0, WeightedScore.computeRatioScore(base, new, weights)) - end) - - it("zero base stat treats denominator as 1 and caps at maxStatIncrease (no div-by-zero crash)", function() - local base = { TotalDPS = 0 } - local new = { TotalDPS = 500 } -- 500/1 = 500, capped at 2 - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - assert.are.equal(2.0, WeightedScore.computeRatioScore(base, new, weights)) - end) + local cases = { + { label = "infinite base", base = { TotalDPS = math.huge }, candidate = { TotalDPS = 1000 }, expected = 0.0 }, + { label = "infinite candidate", base = { TotalDPS = 1000 }, candidate = { TotalDPS = math.huge }, expected = 2.0 }, + { label = "zero base", base = { TotalDPS = 0 }, candidate = { TotalDPS = 500 }, expected = 2.0 }, + { label = "missing stat", base = {}, candidate = {}, expected = 0.0 }, + } - it("missing stat in both base and new scores 0 (no crash)", function() - local base = {} - local new = {} - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - -- 0/1 = 0 - assert.are.equal(0.0, WeightedScore.computeRatioScore(base, new, weights)) + for _, case in ipairs(cases) do + assert.are.equal( + case.expected, + WeightedScore.computeRatioScore(case.base, case.candidate, weights), + case.label + ) + end end) -- computeRatioScore: FullDPS fallback -------------------------------------- @@ -149,44 +115,32 @@ describe("WeightedScore module", function() -- weightsNeedFullDPS: routing helper used by PowerBuilder ------------------ - it("weightsNeedFullDPS returns false for nil weights", function() - assert.is_false(WeightedScore.weightsNeedFullDPS(nil)) - end) - - it("weightsNeedFullDPS returns false for empty weights", function() - assert.is_false(WeightedScore.weightsNeedFullDPS({})) - end) - - it("weightsNeedFullDPS returns true when FullDPS is the only weight", function() - local weights = { { stat = "FullDPS", weightMult = 1.0 } } - assert.is_true(WeightedScore.weightsNeedFullDPS(weights)) - end) - - it("weightsNeedFullDPS returns false when only non-FullDPS weights are present", function() - local weights = { - { stat = "TotalEHP", weightMult = 0.5 }, - { stat = "TotalDPS", weightMult = 1.0 }, - } - assert.is_false(WeightedScore.weightsNeedFullDPS(weights)) - end) - - it("weightsNeedFullDPS returns true when FullDPS appears alongside other weights", function() - local weights = { - { stat = "TotalEHP", weightMult = 0.5 }, - { stat = "FullDPS", weightMult = 1.0 }, - { stat = "Life", weightMult = 0.25 }, + it("weightsNeedFullDPS recognizes active FullDPS weights", function() + local cases = { + { label = "nil", expected = false }, + { label = "empty", weights = {}, expected = false }, + { label = "only FullDPS", weights = { { stat = "FullDPS", weightMult = 1.0 } }, expected = true }, + { + label = "ordinary stats", + weights = { { stat = "TotalEHP", weightMult = 0.5 }, { stat = "TotalDPS", weightMult = 1.0 } }, + expected = false, + }, + { + label = "FullDPS among other stats", + weights = { + { stat = "TotalEHP", weightMult = 0.5 }, + { stat = "FullDPS", weightMult = 1.0 }, + { stat = "Life", weightMult = 0.25 }, + }, + expected = true, + }, + { label = "zero FullDPS weight", weights = { { stat = "FullDPS", weightMult = 0 } }, expected = false }, + { label = "custom stat", weights = { { stat = "TotalAttr", weightMult = 1.0 } }, expected = false }, } - assert.is_true(WeightedScore.weightsNeedFullDPS(weights)) - end) - it("weightsNeedFullDPS returns false when FullDPS weight is zero", function() - local weights = { { stat = "FullDPS", weightMult = 0 } } - assert.is_false(WeightedScore.weightsNeedFullDPS(weights)) - end) - - it("weightsNeedFullDPS returns false for custom-stat-only weights", function() - local weights = { { stat = "TotalAttr", weightMult = 1.0 } } - assert.is_false(WeightedScore.weightsNeedFullDPS(weights)) + for _, case in ipairs(cases) do + assert.are.equal(case.expected, WeightedScore.weightsNeedFullDPS(case.weights), case.label) + end end) end) @@ -196,7 +150,7 @@ describe("WeightedScore — TradeQueryGenerator delegation", function() GetTradeStatusOption = function() return "online" end, }) - it("WeightedRatioOutputs delegates to WeightedScore.computeRatioScore", function() + it("WeightedRatioOutputs delegates ratio calculation and preserves candidate ranking", function() local savedMax = data.misc.maxStatIncrease data.misc.maxStatIncrease = 2 @@ -209,13 +163,11 @@ describe("WeightedScore — TradeQueryGenerator delegation", function() data.misc.maxStatIncrease = savedMax assert.are.equal(direct, delegated) - end) - it("higher-stat candidate ranks above lower-stat candidate", function() - local base = { TotalDPS = 1000 } + base = { TotalDPS = 1000 } local high = { TotalDPS = 1500 } local low = { TotalDPS = 800 } - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + weights = { { stat = "TotalDPS", weightMult = 1.0 } } local highScore = mock_queryGen.WeightedRatioOutputs(base, high, weights) local lowScore = mock_queryGen.WeightedRatioOutputs(base, low, weights) @@ -245,25 +197,18 @@ describe("WeightedScore — tree integration", function() until not build.calcsTab.powerBuilder or iter >= maxIter end - it("registers WeightedScore as the final shared power stat", function() + it("registers WeightedScore as the final shared non-minion power stat", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) assert.are.equal("WeightedScore", data.powerStatList[#data.powerStatList].stat) - end) - - it("does not create a Minion WeightedScore entry", function() assert.is_nil(findStat("MinionWeightedScore")) end) - it("power builder completes without error using WeightedScore stat", function() + it("power builder completes with an initialized non-negative WeightedScore result", function() local stat = findStat("WeightedScore") assert.is_not_nil(stat) drainPowerBuild(stat) assert.is_true(build.calcsTab.powerBuilderInitialized) - end) - - it("powerMax.singleStat is non-negative after WeightedScore build", function() - drainPowerBuild(findStat("WeightedScore")) assert.is_not_nil(build.calcsTab.powerMax) assert.is_true(build.calcsTab.powerMax.singleStat >= 0) end) @@ -310,18 +255,7 @@ describe("WeightedScore — tree integration", function() end) -- Fallback weights must evaluate the configured stats rather than a synthetic output key. - it("getValue on WeightedScore entry returns positive score for better output", function() - local stat = findStat("WeightedScore") - assert.is_not_nil(stat) - assert.is_function(stat.getValue) - local baseOutput = { FullDPS = 100, TotalEHP = 100 } - local betterOutput = { FullDPS = 201, TotalEHP = 100 } - local baseScore = stat.getValue(baseOutput, build, baseOutput) - local betterScore = stat.getValue(betterOutput, build, baseOutput) - assert.is_true(betterScore > baseScore) - end) - - it("power stat helpers keep WeightedScore deltas and baselines on the same scale", function() + it("power stat helpers keep positive WeightedScore deltas and baselines on the same scale", function() local weightedScore = findStat("WeightedScore") local fullDPS = findStat("FullDPS") local life = findStat("Life") @@ -336,6 +270,7 @@ describe("WeightedScore — tree integration", function() local delta = build.calcsTab:CalculatePowerStat(weightedScore, betterOutput, baseOutput) assert.is_true(math.abs(baseValue - 1500) < 0.0001) assert.is_true(math.abs(betterValue - 1700) < 0.0001) + assert.is_true(betterValue > baseValue) assert.is_true(math.abs(delta - 200) < 0.0001) assert.is_true(math.abs(delta / baseValue - 2 / 15) < 0.0001) assert.are.equal(123, data.powerStatList.GetValue({ Life = 123 }, life, build, baseOutput)) @@ -384,18 +319,16 @@ describe("WeightedScore — tree integration", function() } } } end - it("appendEditWeightsAction is a no-op when the list has no WeightedScore entry", function() - local list = { + it("appendEditWeightsAction handles lists with and without WeightedScore", function() + local ordinaryList = { { label = "Sort by Name", sortMode = "name" }, { label = "Sort by Life", sortMode = "Life" }, } local called = false - WeightedScore.appendEditWeightsAction(list, function() called = true end) - assert.are.equal(2, #list) + WeightedScore.appendEditWeightsAction(ordinaryList, function() called = true end) + assert.are.equal(2, #ordinaryList) assert.is_false(called) - end) - it("appendEditWeightsAction appends an action after WeightedScore", function() local list = { { label = "Sort by Name", sortMode = "name" }, { label = "Sort by Weighted Score", sortMode = "WeightedScore", stat = "WeightedScore" }, From 06b7e8e98b43a2dd7e9c69c54f109959a13ccab0 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 21 Aug 2026 10:21:19 +0200 Subject: [PATCH 30/37] Extract Power Report fix from weighted score Keep the generic allocated-node filter and its regression coverage on the dedicated Power Report bugfix branch. --- src/Classes/PowerReportListControl.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Classes/PowerReportListControl.lua b/src/Classes/PowerReportListControl.lua index 6472b01eeb2..69738aac11f 100644 --- a/src/Classes/PowerReportListControl.lua +++ b/src/Classes/PowerReportListControl.lua @@ -112,8 +112,6 @@ function PowerReportListClass:ReList() end if self.allocated then insert = item.allocated - elseif item.allocated then - insert = false end if not self.showMasteries and item.type == "Mastery" then insert = false From 2dca1500ca957faf87faabd8734239770394d087 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 21 Aug 2026 10:56:29 +0200 Subject: [PATCH 31/37] Clarify weighted score semantic contracts Align stat, output, and selection names with their actual data shapes, and document the shared editor callback and score scaling invariants. --- spec/System/TestNotableDBControl_spec.lua | 2 +- spec/System/TestWeightedScore_spec.lua | 22 ++++++------- src/Classes/TreeTab.lua | 16 +++++----- src/Modules/Data.lua | 16 +++++----- src/Modules/WeightedScore.lua | 38 +++++++++++++---------- 5 files changed, 49 insertions(+), 45 deletions(-) diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua index 7bb933f938c..50bc940e302 100644 --- a/spec/System/TestNotableDBControl_spec.lua +++ b/spec/System/TestNotableDBControl_spec.lua @@ -25,7 +25,7 @@ describe("NotableDBControl", function() end, }, } - local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANNOINT") + local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANOINT") for _, stat in ipairs(data.powerStatList) do if stat.stat == "WeightedScore" then control.sortDetail = copyTable(stat) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 8a9311a934e..fb05904f00c 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -113,9 +113,9 @@ describe("WeightedScore module", function() assert.are.equal(1.5, WeightedScore.computeRatioScore(base, new, weights)) end) - -- weightsNeedFullDPS: routing helper used by PowerBuilder ------------------ + -- weightsRequireFullDPS: FullDPS requirement used by PowerBuilder ---------- - it("weightsNeedFullDPS recognizes active FullDPS weights", function() + it("weightsRequireFullDPS identifies active FullDPS weights", function() local cases = { { label = "nil", expected = false }, { label = "empty", weights = {}, expected = false }, @@ -139,7 +139,7 @@ describe("WeightedScore module", function() } for _, case in ipairs(cases) do - assert.are.equal(case.expected, WeightedScore.weightsNeedFullDPS(case.weights), case.label) + assert.are.equal(case.expected, WeightedScore.weightsRequireFullDPS(case.weights), case.label) end end) end) @@ -189,12 +189,12 @@ describe("WeightedScore — tree integration", function() local function drainPowerBuild(stat) build.calcsTab.powerBuildFlag = true build.calcsTab.powerStat = stat or findStat("Life") - local maxIter = 100000 - local iter = 0 + local maxIterations = 100000 + local iterations = 0 repeat build.calcsTab:BuildPower() - iter = iter + 1 - until not build.calcsTab.powerBuilder or iter >= maxIter + iterations = iterations + 1 + until not build.calcsTab.powerBuilder or iterations >= maxIterations end it("registers WeightedScore as the final shared non-minion power stat", function() @@ -477,7 +477,7 @@ describe("WeightedScore — crafted affix sorting", function() data.powerStatList.GetValue = originalGetValue end) - it("evaluates contextual scores with their baseline and Full DPS requirement", function() + it("evaluates baseline-dependent scores with their reference output and Full DPS requirement", function() local itemsTab = build.itemsTab itemsTab:CreateDisplayItemFromRaw([[ Rarity: RARE @@ -529,14 +529,14 @@ Implicits: 0 { } ) - assert.is_true(getValueCalls > 0, "crafted affix sorting must evaluate contextual stats through GetValue") - assert.is_true(sawBaseline, "contextual scoring must receive the item without the candidate affix") + assert.is_true(getValueCalls > 0, "crafted affix sorting must evaluate baseline-dependent stats through GetValue") + assert.is_true(sawBaseline, "baseline-dependent scoring must receive the item without the candidate affix") assert.is_true(#useFullDPSCalls > 0) for _, useFullDPS in ipairs(useFullDPSCalls) do assert.is_true(useFullDPS) end assert.is_not_nil(control.list[2].modList) local highestScoredMod = itemsTab.displayItem.affixes[control.list[2].modList[1]] - assert.are.equal(2, #highestScoredMod, "the highest contextual score must sort first") + assert.are.equal(2, #highestScoredMod, "the highest baseline-dependent score must sort first") end) end) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index a900a95e661..b7418ab1442 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -2143,26 +2143,26 @@ function TreeTabClass:FindTimelessJewel() t_insert(fallbackWeightsList, fallbackWeight) end end - local activeFallbackWeightIndex = timelessData.fallbackWeightMode.idx or 1 - if not fallbackWeightsList[activeFallbackWeightIndex] then - activeFallbackWeightIndex = 1 + local selectedFallbackStatIndex = timelessData.fallbackWeightMode.idx or 1 + if not fallbackWeightsList[selectedFallbackStatIndex] then + selectedFallbackStatIndex = 1 end - local activeFallbackWeight = fallbackWeightsList[activeFallbackWeightIndex] + local selectedFallbackStat = fallbackWeightsList[selectedFallbackStatIndex] WeightedScore.appendEditWeightsAction(fallbackWeightsList, function() - controls.fallbackWeightsList:SelByValue(activeFallbackWeight.stat, "stat") + controls.fallbackWeightsList:SelByValue(selectedFallbackStat.stat, "stat") WeightedScore.editWeights(self.build) end) controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index, value) if value.action then value.action() else - activeFallbackWeightIndex = index - activeFallbackWeight = value + selectedFallbackStatIndex = index + selectedFallbackStat = value timelessData.fallbackWeightMode.idx = index end end) controls.fallbackWeightsLabel = new("LabelControl"):LabelControl({"RIGHT", controls.fallbackWeightsList, "LEFT"}, {-labelSpacing, 0, 0, labelHeight}, "^7Fallback Weight Mode:") - controls.fallbackWeightsList.selIndex = activeFallbackWeightIndex + controls.fallbackWeightsList.selIndex = selectedFallbackStatIndex controls.fallbackWeightsButton = new("ButtonControl"):ButtonControl({"LEFT", controls.fallbackWeightsList, "RIGHT"}, {5, 0, 66, 18}, "Generate", function() setupFallbackWeights() controls.searchListFallbackButton.label = "^4Fallback Nodes" diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index c5eac22074b..8c327aaac8a 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -262,18 +262,18 @@ t_insert(data.powerStatList, { stat="WeightedScore", label="Weighted Score", requiresFullDPS=function(build) - return WeightedScore.weightsNeedFullDPS(WeightedScore.getWeights(build)) + return WeightedScore.weightsRequireFullDPS(WeightedScore.getWeights(build)) end, getValue=function(output, build, calcBase) local weights = WeightedScore.getWeights(build) - local buildBase = calcBase - if not buildBase then - local _, cachedBuildBase = build.calcsTab:GetMiscCalculator() - buildBase = cachedBuildBase + local baselineOutput = calcBase + if not baselineOutput then + local _, cachedBaselineOutput = build.calcsTab:GetMiscCalculator() + baselineOutput = cachedBaselineOutput end - -- Keep this synthetic stat on the trade-query weight scale. Calcs and Compare - -- subtract candidate and baseline values; other consumers rank or normalize it. - return WeightedScore.computeRatioScore(buildBase, output, weights) * 1000 + -- Trader multiplies ratio scores by 1000 to create weight points. Reuse it so + -- Calcs/Compare deltas and every ranking or normalization preserve one scale. + return WeightedScore.computeRatioScore(baselineOutput, output, weights) * 1000 end, }) data.misc = { -- magic numbers diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua index ebd8e162841..644b0c63ad6 100644 --- a/src/Modules/WeightedScore.lua +++ b/src/Modules/WeightedScore.lua @@ -17,13 +17,15 @@ end -- Returns the current stat weight list from the build's trade query settings, -- falling back to defaults if none are configured or the build is not available. function WeightedScore.getWeights(build) - local tq = build and build.itemsTab and build.itemsTab.tradeQuery - if tq and tq.statSortSelectionList and #tq.statSortSelectionList > 0 then - return tq.statSortSelectionList + local tradeQuery = build and build.itemsTab and build.itemsTab.tradeQuery + if tradeQuery and tradeQuery.statSortSelectionList and #tradeQuery.statSortSelectionList > 0 then + return tradeQuery.statSortSelectionList end return WeightedScore.defaultWeights() end +-- Open the shared Trade Query weight editor. onSave only runs after Save, so +-- consumers can invalidate derived scores without reacting to Cancel. function WeightedScore.editWeights(build, onSave) local tradeQuery = build and build.itemsTab and build.itemsTab.tradeQuery if tradeQuery then @@ -33,7 +35,7 @@ end -- Returns true when any active weight targets FullDPS, so callers can route -- through the FullDPS-aware calculation path. -function WeightedScore.weightsNeedFullDPS(weights) +function WeightedScore.weightsRequireFullDPS(weights) if not weights then return false end @@ -54,37 +56,37 @@ end function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) local weightedScore = 0.0 local function computeStatSumRatio(...) - local baseModSum = 0 - local newModSum = 0 + local baseStatSum = 0 + local candidateStatSum = 0 for _, statTable in ipairs({ ... }) do - baseModSum = baseModSum + data.powerStatList.GetFromOutput(baseOutput, statTable, true) - newModSum = newModSum + data.powerStatList.GetFromOutput(newOutput, statTable, true) + baseStatSum = baseStatSum + data.powerStatList.GetFromOutput(baseOutput, statTable, true) + candidateStatSum = candidateStatSum + data.powerStatList.GetFromOutput(newOutput, statTable, true) end - if baseModSum == math.huge then + if baseStatSum == math.huge then return 0 - elseif newModSum == math.huge then + elseif candidateStatSum == math.huge then return data.misc.maxStatIncrease else - return math.min(newModSum / ((baseModSum ~= 0) and baseModSum or 1), data.misc.maxStatIncrease) + return math.min(candidateStatSum / ((baseStatSum ~= 0) and baseStatSum or 1), data.misc.maxStatIncrease) end end for _, statTable in ipairs(weights) do - local modSumRatio + local statSumRatio if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then -- FullDPS fallback: use combined DPS components when FullDPS is not directly available - modSumRatio = computeStatSumRatio({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) + statSumRatio = computeStatSumRatio({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) else - modSumRatio = computeStatSumRatio(statTable) + statSumRatio = computeStatSumRatio(statTable) end if statTable.transform then - modSumRatio = statTable.transform(modSumRatio) + statSumRatio = statTable.transform(statSumRatio) end - weightedScore = weightedScore + modSumRatio * statTable.weightMult + weightedScore = weightedScore + statSumRatio * statTable.weightMult end return weightedScore end --- Append a contextual "Edit Weights..." action after WeightedScore so the +-- Append "Edit Weights..." after WeightedScore so the -- score remains the final metric while its configuration stays adjacent. function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) for _, entry in ipairs(sortDropList) do @@ -98,6 +100,8 @@ function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) end end +-- Keep the selected metric while opening the editor. Saving clears cached +-- candidate scores before reapplying that metric; cancelling leaves them intact. function WeightedScore.createSortHandler(sortDropList, controls, build, applySort, clearSortValues) local activeSort = sortDropList[1] WeightedScore.appendEditWeightsAction(sortDropList, function() From 35d8eb264e4a9f2ee49bd3fb014d3a9170478840 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 21 Aug 2026 23:45:07 +0200 Subject: [PATCH 32/37] Fix contextual weighted score references Rank contextual Anoint and crafted-affix candidates against the displayed item while preserving scalar Anoint deltas against the no-anoint baseline. --- spec/System/TestNotableDBControl_spec.lua | 98 ++++++++++++++++++----- spec/System/TestWeightedScore_spec.lua | 36 ++++----- src/Classes/ItemsTab.lua | 3 +- src/Classes/NotableDBControl.lua | 4 +- 4 files changed, 99 insertions(+), 42 deletions(-) diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua index 50bc940e302..e1212566c2a 100644 --- a/spec/System/TestNotableDBControl_spec.lua +++ b/spec/System/TestNotableDBControl_spec.lua @@ -1,15 +1,33 @@ describe("NotableDBControl", function() - it("requests FullDPS when sorting by WeightedScore with FullDPS weights", function() - local notable = { - dn = "Full DPS Notable", + local function findPowerStat(statName) + for _, stat in ipairs(data.powerStatList) do + if stat.stat == statName then + return copyTable(stat) + end + end + end + + it("sorts WeightedScore anoints against the displayed item", function() + local candidateA = { + dn = "Candidate A", sd = {}, recipe = { "Amber Oil" }, - modKey = "NotableFullDPS", + modKey = "CandidateA", } + local candidateB = { + dn = "Candidate B", + sd = {}, + recipe = { "Azure Oil" }, + modKey = "CandidateB", + } + local displayItem = { base = { type = "Amulet" } } local requestedFullDPS = {} local itemsTab = { - displayItem = { base = { type = "Amulet" } }, - tradeQuery = { statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } }, + displayItem = displayItem, + tradeQuery = { statSortSelectionList = { + { stat = "FullDPS", weightMult = 1 }, + { stat = "TotalEHP", weightMult = 1 }, + } }, anointItem = function(_, node) return node end, @@ -20,26 +38,68 @@ describe("NotableDBControl", function() GetMiscCalculator = function() return function(args, useFullDPS) table.insert(requestedFullDPS, useFullDPS) - return { FullDPS = args.repItem and 120 or 100 } + if args.repItem == displayItem then + return { FullDPS = 200, TotalEHP = 100 } + elseif args.repItem == candidateA then + return { FullDPS = 180, TotalEHP = 120 } + elseif args.repItem == candidateB then + return { FullDPS = 150, TotalEHP = 140 } + end + return { FullDPS = 100, TotalEHP = 100 } end end, }, } - local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANOINT") - for _, stat in ipairs(data.powerStatList) do - if stat.stat == "WeightedScore" then - control.sortDetail = copyTable(stat) - break - end - end + local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { candidateA, candidateB }, "ANOINT") + control.sortDetail = findPowerStat("WeightedScore") + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.same({ true, true, true }, requestedFullDPS) + assert.are.equal(candidateB, control.list[1]) + assert.are.equal(candidateA, control.list[2]) + assert.is_true(candidateB.measuredPower > candidateA.measuredPower) + assert.are.equal(candidateB.measuredPower, control.sortMaxPower) + assert.are.equal("^xFF8080Candidate B", control:GetRowValue(1, 1, candidateB)) + end) + + it("keeps scalar anoint impact relative to the item without an anoint", function() + local notable = { + dn = "Scalar Candidate", + sd = {}, + recipe = { "Amber Oil" }, + modKey = "ScalarCandidate", + } + local displayItem = { base = { type = "Amulet" } } + local itemsTab = { + displayItem = displayItem, + anointItem = function(_, node) + return node + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(args) + if args.repItem == displayItem then + return { FullDPS = 200 } + elseif args.repItem == notable then + return { FullDPS = 180 } + end + return { FullDPS = 100 } + end + end, + }, + } + local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { notable }, "ANOINT") + control.sortDetail = findPowerStat("FullDPS") control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() - assert.are.same({ true, true }, requestedFullDPS) - assert.are.equal(notable, control.list[1]) - assert.is_true(notable.measuredPower > 0) - assert.are.equal(notable.measuredPower, control.sortMaxPower) - assert.are.equal("^xFF8080Full DPS Notable", control:GetRowValue(1, 1, notable)) + assert.are.equal(80, notable.measuredPower) + assert.are.equal("^xFF8080Scalar Candidate", control:GetRowValue(1, 1, notable)) end) end) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index fb05904f00c..2cb385bbe60 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -464,20 +464,17 @@ end) describe("WeightedScore — crafted affix sorting", function() local originalGetMiscCalculator - local originalGetValue before_each(function() newBuild() originalGetMiscCalculator = build.calcsTab.GetMiscCalculator - originalGetValue = data.powerStatList.GetValue end) after_each(function() build.calcsTab.GetMiscCalculator = originalGetMiscCalculator - data.powerStatList.GetValue = originalGetValue end) - it("evaluates baseline-dependent scores with their reference output and Full DPS requirement", function() + it("sorts crafted affixes against the displayed item", function() local itemsTab = build.itemsTab itemsTab:CreateDisplayItemFromRaw([[ Rarity: RARE @@ -485,7 +482,7 @@ Weighted Sort Helmet Royal Burgonet Item Level: 86 Crafted: true -Prefix: None +Prefix: {range:0.5}IncreasedLife6 Prefix: None Prefix: None Suffix: None @@ -493,9 +490,11 @@ Suffix: None Suffix: None Quality: 20 Implicits: 0 ++65 to maximum Life ]]) itemsTab.tradeQuery.statSortSelectionList = { { stat = "FullDPS", label = "Full DPS", weightMult = 1 }, + { stat = "TotalEHP", label = "Effective Hit Pool", weightMult = 1 }, } itemsTab.controls.craftingSorting:SelByValue("WeightedScore", "stat") @@ -503,22 +502,19 @@ Implicits: 0 build.calcsTab.GetMiscCalculator = function() return function(params, useFullDPS) useFullDPSCalls[#useFullDPSCalls + 1] = useFullDPS - return { - modCount = params and params.repItem and #params.repItem.explicitModLines or 0, - } + local item = params and params.repItem + local modCount = item and #item.explicitModLines or 0 + if item == itemsTab.displayItem then + return { FullDPS = 200, TotalEHP = 100 } + elseif modCount >= 2 then + return { FullDPS = 180, TotalEHP = 120 } + elseif modCount == 1 then + return { FullDPS = 150, TotalEHP = 140 } + end + return { FullDPS = 100, TotalEHP = 100 } end end - local getValueCalls = 0 - local sawBaseline = false - data.powerStatList.GetValue = function(output, statTable, ownerBuild, calcBase) - getValueCalls = getValueCalls + 1 - sawBaseline = sawBaseline or (calcBase and calcBase.modCount == 0) - assert.are.equal("WeightedScore", statTable.stat) - assert.are.equal(build, ownerBuild) - return output.modCount - end - local control = itemsTab.controls.displayItemAffix1 itemsTab:UpdateAffixControl( control, @@ -529,14 +525,12 @@ Implicits: 0 { } ) - assert.is_true(getValueCalls > 0, "crafted affix sorting must evaluate baseline-dependent stats through GetValue") - assert.is_true(sawBaseline, "baseline-dependent scoring must receive the item without the candidate affix") assert.is_true(#useFullDPSCalls > 0) for _, useFullDPS in ipairs(useFullDPSCalls) do assert.is_true(useFullDPS) end assert.is_not_nil(control.list[2].modList) local highestScoredMod = itemsTab.displayItem.affixes[control.list[2].modList[1]] - assert.are.equal(2, #highestScoredMod, "the highest baseline-dependent score must sort first") + assert.are.equal(1, #highestScoredMod, "the best modifier for the displayed item must sort first") end) end) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index e12d9d30d98..54d380f6933 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -2338,8 +2338,9 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, testSubject:Craft() controlPowerCache = { } end + -- Contextual stats use the displayed item as their reference; testSubject only builds replacement candidates. local calcBase = sortOption.getValue - and calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS) + and calcFunc({ repSlotName = slotName, repItem = self.displayItem }, useFullDPS) local function pickModifierFromList(modList) -- pick mid tier modifier from a group if #modList == 1 then diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index e419cc0f8d8..5664b6e0317 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -144,7 +144,9 @@ function NotableDBClass:ListBuilder() local calcFunc = self.itemsTab.build.calcsTab:GetMiscCalculator() local itemType = self.itemsTab.displayItem.base.type local useFullDPS = data.powerStatList.RequiresFullDPS(self.sortDetail, self.itemsTab.build) - local calcBase = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(nil) }, useFullDPS) + -- Contextual stats compare with the displayed item; scalar stats retain the historical no-anoint delta. + local referenceItem = self.sortDetail.getValue and self.itemsTab.displayItem or self.itemsTab:anointItem(nil) + local calcBase = calcFunc({ repSlotName = itemType, repItem = referenceItem }, useFullDPS) self.sortMaxPower = 0 for nodeIndex, node in ipairs(list) do node.measuredPower = 0 From e0c6986e5d9570108290b6196515a5d06e5f88e9 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 23 Aug 2026 09:06:43 +0200 Subject: [PATCH 33/37] Compact weighted score test contracts Share local fixtures and bound the Power Builder smoke to reduce review cost. Replace the Timeless editor-only check with real Weighted Score fallback generation. --- spec/System/TestAbyssTimelessJewel_spec.lua | 73 +++++--- spec/System/TestItemDBControl_spec.lua | 184 +++++++------------- spec/System/TestNotableDBControl_spec.lua | 100 ++++------- spec/System/TestTradeQuery_spec.lua | 58 +++--- spec/System/TestWeightedScore_spec.lua | 110 ++++-------- 5 files changed, 197 insertions(+), 328 deletions(-) diff --git a/spec/System/TestAbyssTimelessJewel_spec.lua b/spec/System/TestAbyssTimelessJewel_spec.lua index 9e8f19cb5be..3c5adca63cd 100644 --- a/spec/System/TestAbyssTimelessJewel_spec.lua +++ b/spec/System/TestAbyssTimelessJewel_spec.lua @@ -300,40 +300,63 @@ describe("Abyss timeless jewels", function() assert.matches("abyss_special_small_attribute25, 1, 0, 0", build.timelessData.searchListFallback, nil, true) end) - it("exposes weight editing beside the Weighted Score fallback mode", function() + it("generates useful non-uniform fallback weights with Weighted Score", function() + build.skillsTab:PasteSocketGroup("Ethereal Knives 20/0 1\n") + runCallback("OnFrame") + local socketId + for id, node in pairs(build.spec.nodes) do + if node.isJewelSocket and node.name ~= "Charm Socket" then + socketId = id + break + end + end + assert.is_truthy(socketId, "fixture requires a passive-tree jewel socket") + build.timelessData.jewelType = { id = 11 } build.timelessData.conquerorType = { } - build.timelessData.jewelSocket = { id = 26196 } + build.timelessData.jewelSocket = { id = socketId } build.itemsTab.tradeQuery.statSortSelectionList = { { stat = "FullDPS", label = "Full DPS", weightMult = 1 }, + { stat = "TotalEHP", label = "Effective Hit Pool", weightMult = 0.5 }, } build.treeTab:FindTimelessJewel() - local control = main.popups[1].controls.fallbackWeightsList - local weightedIndex - local weightedCount = 0 - for index, entry in ipairs(control.list) do - if entry.stat == "WeightedScore" then - weightedIndex = index - weightedCount = weightedCount + 1 - end - end - assert.are.equal(1, weightedCount) - assert.is_truthy(weightedIndex) - assert.is_function(control.list[weightedIndex + 1].action) - assert.is_true(data.powerStatList.RequiresFullDPS(control.list[weightedIndex], build)) - - local opened = false - build.itemsTab.tradeQuery.SetStatWeights = function() - opened = true + local controls = main.popups[1].controls + controls.fallbackWeightsList:SelByValue("WeightedScore", "stat") + assert.are.equal("WeightedScore", controls.fallbackWeightsList:GetSelValue().stat) + + local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator + local requestedFullDPS = { } + build.calcsTab.GetMiscCalculator = function(self, ...) + local calcFunc, calcBase = originalGetMiscCalculator(self, ...) + return function(params, useFullDPS) + requestedFullDPS[#requestedFullDPS + 1] = useFullDPS + return calcFunc(params, useFullDPS) + end, calcBase end - control:SetSel(weightedIndex) - for char in ("Edit"):gmatch(".") do - control:OnSearchChar(char) + local ok, errMsg = pcall(controls.fallbackWeightsButton.onClick) + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + assert.is_true(ok, errMsg) + assert.is_true(#requestedFullDPS > 1, "Generate must evaluate a baseline and candidates") + for _, useFullDPS in ipairs(requestedFullDPS) do + assert.is_true(useFullDPS) end - control:SetSel(1) - assert.is_true(opened) - assert.are.equal("WeightedScore", control:GetSelValue().stat) + local distinctWeights = { } + local usefulWeightCount = 0 + for line in build.timelessData.searchListFallback:gmatch("[^\r\n]+") do + local weight1, weight2 = line:match("^[^,]+,%s*([^,]+),%s*([^,]+)") + weight1, weight2 = tonumber(weight1), tonumber(weight2) + if weight1 and weight2 and math.abs(weight1) + math.abs(weight2) > 0 then + usefulWeightCount = usefulWeightCount + 1 + distinctWeights[weight1 .. "," .. weight2] = true + end + end + local distinctWeightCount = 0 + for _ in pairs(distinctWeights) do + distinctWeightCount = distinctWeightCount + 1 + end + assert.is_true(usefulWeightCount > 0, "Generate must produce at least one useful fallback weight") + assert.is_true(distinctWeightCount > 1, "Weighted Score must not collapse every node to one weight") end) it("reads Zorath seed 6564 node and Inquisitor ascendancy changes", function() diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua index 94ec9d995cb..d1ab1729522 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -7,6 +7,52 @@ describe("ItemDBControl", function() end end end + local function newItem(name) + return { + name = name, + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + end + local function newRankingFixture(weights) + local items = { + better = newItem("Better Item"), + worse = newItem("Worse Item"), + invalid = newItem("Invalid Item"), + } + local values = { + [items.better] = { PhysicalTakenHit = 80, FullDPS = 120 }, + [items.worse] = { PhysicalTakenHit = 120, FullDPS = 80 }, + } + local requestedFullDPS = { } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + tradeQuery = { statSortSelectionList = weights or {} }, + IsItemValidForSlot = function(_, item) + return item ~= items.invalid + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(args, useFullDPS) + requestedFullDPS[#requestedFullDPS + 1] = useFullDPS + return values[args.repItem] + end, { PhysicalTakenHit = 100, FullDPS = 100 } + end, + }, + } + local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { + list = { items.invalid, items.better, items.worse }, + }, "RARE") + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + return control, items, requestedFullDPS + end before_each(function() originalGetCursorPos = GetCursorPos @@ -17,153 +63,43 @@ describe("ItemDBControl", function() end) it("sorts lower-is-better stats below zero", function() - local function makeItem(name) - return { - name = name, - base = {}, - enchantModLines = {}, - implicitModLines = {}, - explicitModLines = {}, - baseModList = {}, - } - end - local betterItem = makeItem("Better Item") - local worseItem = makeItem("Worse Item") - local invalidItem = makeItem("Invalid Item") - local takenDamage = { - [betterItem] = 80, - [worseItem] = 120, - } - local itemsTab = { - activeItemSet = { useSecondWeaponSet = false }, - slots = { ["Body Armour"] = {} }, - build = { - calcsTab = { - GetMiscCalculator = function() - return function(args) - return { PhysicalTakenHit = takenDamage[args.repItem] } - end - end, - }, - }, - IsItemValidForSlot = function(_, item) - return item ~= invalidItem - end, - } - local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { - list = { invalidItem, betterItem, worseItem }, - }, "RARE") + local control, items = newRankingFixture() control.sortDetail = { stat = "PhysicalTakenHit", transform = function(value) return -value end, } - control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() - assert.are.equal(betterItem, control.list[1]) - assert.are.equal(worseItem, control.list[2]) - assert.are.equal(invalidItem, control.list[3]) - assert.are.equal(-80, betterItem.measuredPower) - assert.are.equal(-120, worseItem.measuredPower) - assert.are.equal(-math.huge, invalidItem.measuredPower) + assert.are.same({ items.better, items.worse, items.invalid }, control.list) + assert.are.equal(-80, items.better.measuredPower) + assert.are.equal(-120, items.worse.measuredPower) + assert.are.equal(-math.huge, items.invalid.measuredPower) end) it("preserves negative WeightedScore results and skips unneeded FullDPS", function() - local function makeItem(name) - return { - name = name, - base = {}, - enchantModLines = {}, - implicitModLines = {}, - explicitModLines = {}, - baseModList = {}, - } - end - local betterItem = makeItem("Better Item") - local worseItem = makeItem("Worse Item") - local invalidItem = makeItem("Invalid Item") - local takenDamage = { - [betterItem] = 80, - [worseItem] = 120, + local weights = { + { stat = "PhysicalTakenHit", weightMult = 1, transform = function(value) return -value end }, } - local requestedFullDPS = { } - local itemsTab = { - activeItemSet = { useSecondWeaponSet = false }, - slots = { ["Body Armour"] = {} }, - tradeQuery = { - statSortSelectionList = { - { stat = "PhysicalTakenHit", weightMult = 1, transform = function(value) return -value end }, - }, - }, - IsItemValidForSlot = function(_, item) - return item ~= invalidItem - end, - } - itemsTab.build = { - itemsTab = itemsTab, - calcsTab = { - GetMiscCalculator = function() - return function(args, useFullDPS) - table.insert(requestedFullDPS, useFullDPS) - return { PhysicalTakenHit = takenDamage[args.repItem] } - end, { PhysicalTakenHit = 100 } - end, - }, - } - local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { - list = { invalidItem, betterItem, worseItem }, - }, "RARE") + local control, items, requestedFullDPS = newRankingFixture(weights) control.sortDetail = copyTable(findPowerStat("WeightedScore")) - control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() - assert.are.equal(betterItem, control.list[1]) - assert.are.equal(worseItem, control.list[2]) - assert.are.equal(invalidItem, control.list[3]) - assert.is_true(betterItem.measuredPower < 0) - assert.is_true(worseItem.measuredPower < betterItem.measuredPower) - assert.are.equal(-math.huge, invalidItem.measuredPower) + assert.are.same({ items.better, items.worse, items.invalid }, control.list) + assert.is_true(items.better.measuredPower < 0) + assert.is_true(items.worse.measuredPower < items.better.measuredPower) + assert.are.equal(-math.huge, items.invalid.measuredPower) assert.are.same({ false, false }, requestedFullDPS) end) it("requests FullDPS for WeightedScore when active weights need it", function() - local item = { - name = "Full DPS Item", - base = {}, - enchantModLines = {}, - implicitModLines = {}, - explicitModLines = {}, - baseModList = {}, - } - local requestedFullDPS = { } - local itemsTab = { - activeItemSet = { useSecondWeaponSet = false }, - slots = { ["Body Armour"] = {} }, - tradeQuery = { statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } }, - IsItemValidForSlot = function() - return true - end, - } - itemsTab.build = { - itemsTab = itemsTab, - calcsTab = { - GetMiscCalculator = function() - return function(_, useFullDPS) - table.insert(requestedFullDPS, useFullDPS) - return { FullDPS = 120 } - end, { FullDPS = 100 } - end, - }, - } - local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") + local control, _, requestedFullDPS = newRankingFixture({ { stat = "FullDPS", weightMult = 1 } }) control.sortDetail = copyTable(findPowerStat("WeightedScore")) - control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() - assert.are.same({ true }, requestedFullDPS) + assert.are.same({ true, true }, requestedFullDPS) end) it("searches Foulborn modifier text without case sensitivity", function() diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua index e1212566c2a..20f41301072 100644 --- a/spec/System/TestNotableDBControl_spec.lua +++ b/spec/System/TestNotableDBControl_spec.lua @@ -6,100 +6,62 @@ describe("NotableDBControl", function() end end end - - it("sorts WeightedScore anoints against the displayed item", function() - local candidateA = { - dn = "Candidate A", - sd = {}, - recipe = { "Amber Oil" }, - modKey = "CandidateA", - } - local candidateB = { - dn = "Candidate B", - sd = {}, - recipe = { "Azure Oil" }, - modKey = "CandidateB", + local function newAnointFixture(weights, includeSecondCandidate) + local candidates = { + { dn = "Candidate A", sd = {}, recipe = { "Fixture Oil" }, modKey = "CandidateA" }, + { dn = "Candidate B", sd = {}, recipe = { "Fixture Oil" }, modKey = "CandidateB" }, } local displayItem = { base = { type = "Amulet" } } - local requestedFullDPS = {} + local outputs = { + [displayItem] = { FullDPS = 200, TotalEHP = 100 }, + [candidates[1]] = { FullDPS = 180, TotalEHP = 120 }, + [candidates[2]] = { FullDPS = 150, TotalEHP = 140 }, + } + local requestedFullDPS = { } local itemsTab = { displayItem = displayItem, - tradeQuery = { statSortSelectionList = { - { stat = "FullDPS", weightMult = 1 }, - { stat = "TotalEHP", weightMult = 1 }, - } }, - anointItem = function(_, node) - return node - end, + tradeQuery = { statSortSelectionList = weights or {} }, + anointItem = function(_, node) return node end, } itemsTab.build = { itemsTab = itemsTab, calcsTab = { GetMiscCalculator = function() return function(args, useFullDPS) - table.insert(requestedFullDPS, useFullDPS) - if args.repItem == displayItem then - return { FullDPS = 200, TotalEHP = 100 } - elseif args.repItem == candidateA then - return { FullDPS = 180, TotalEHP = 120 } - elseif args.repItem == candidateB then - return { FullDPS = 150, TotalEHP = 140 } - end - return { FullDPS = 100, TotalEHP = 100 } + requestedFullDPS[#requestedFullDPS + 1] = useFullDPS + return outputs[args.repItem] or { FullDPS = 100, TotalEHP = 100 } end end, }, } - local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { candidateA, candidateB }, "ANOINT") - control.sortDetail = findPowerStat("WeightedScore") + local list = includeSecondCandidate and candidates or { candidates[1] } + local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, list, "ANOINT") control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + return control, candidates, requestedFullDPS + end + + it("sorts WeightedScore anoints against the displayed item", function() + local weights = { + { stat = "FullDPS", weightMult = 1 }, + { stat = "TotalEHP", weightMult = 1 }, + } + local control, candidates, requestedFullDPS = newAnointFixture(weights, true) + control.sortDetail = findPowerStat("WeightedScore") control:ListBuilder() assert.are.same({ true, true, true }, requestedFullDPS) - assert.are.equal(candidateB, control.list[1]) - assert.are.equal(candidateA, control.list[2]) - assert.is_true(candidateB.measuredPower > candidateA.measuredPower) - assert.are.equal(candidateB.measuredPower, control.sortMaxPower) - assert.are.equal("^xFF8080Candidate B", control:GetRowValue(1, 1, candidateB)) + assert.are.same({ candidates[2], candidates[1] }, control.list) + assert.is_true(candidates[2].measuredPower > candidates[1].measuredPower) + assert.are.equal(candidates[2].measuredPower, control.sortMaxPower) end) it("keeps scalar anoint impact relative to the item without an anoint", function() - local notable = { - dn = "Scalar Candidate", - sd = {}, - recipe = { "Amber Oil" }, - modKey = "ScalarCandidate", - } - local displayItem = { base = { type = "Amulet" } } - local itemsTab = { - displayItem = displayItem, - anointItem = function(_, node) - return node - end, - } - itemsTab.build = { - itemsTab = itemsTab, - calcsTab = { - GetMiscCalculator = function() - return function(args) - if args.repItem == displayItem then - return { FullDPS = 200 } - elseif args.repItem == notable then - return { FullDPS = 180 } - end - return { FullDPS = 100 } - end - end, - }, - } - local control = new("NotableDBControl"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { notable }, "ANOINT") + local control, candidates = newAnointFixture() control.sortDetail = findPowerStat("FullDPS") - control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } control:ListBuilder() - assert.are.equal(80, notable.measuredPower) - assert.are.equal("^xFF8080Scalar Candidate", control:GetRowValue(1, 1, notable)) + assert.are.equal(80, candidates[1].measuredPower) end) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c176fa22eec..a7a8fb3c42b 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -178,57 +178,47 @@ describe("TradeQuery", function() end) describe("SetStatWeights", function() - it("marks the build modified after saving changed weights", function() - local capturedControls - local originalOpenPopup = main.OpenPopup - local originalClosePopup = main.ClosePopup + local capturedControls + local originalOpenPopup + local originalClosePopup + before_each(function() + originalOpenPopup = main.OpenPopup + originalClosePopup = main.ClosePopup main.OpenPopup = function(_, _, _, _, controls) capturedControls = controls end main.ClosePopup = function() end + end) + after_each(function() + main.OpenPopup = originalOpenPopup + main.ClosePopup = originalClosePopup + end) + it("marks the build modified after saving changed weights", function() local itemsTab = {} local tradeQuery = new("TradeQuery"):TradeQuery(itemsTab) - local ok, errMsg = pcall(function() - tradeQuery:SetStatWeights() - for _, entry in ipairs(capturedControls.ListControl.list) do - if entry.stat.stat == "FullDPS" then - entry.stat.weightMult = 0.75 - break - end + tradeQuery:SetStatWeights() + for _, entry in ipairs(capturedControls.ListControl.list) do + if entry.stat.stat == "FullDPS" then + entry.stat.weightMult = 0.75 + break end - capturedControls.finalise.onClick() - end) - main.OpenPopup = originalOpenPopup - main.ClosePopup = originalClosePopup + end + capturedControls.finalise.onClick() - assert.is_true(ok, errMsg) assert.is_true(itemsTab.modFlag) assert.are.equal(0.75, tradeQuery.statSortSelectionList[1].weightMult) end) it("preserves the save callback after resetting weights", function() - local capturedControls - local originalOpenPopup = main.OpenPopup - local originalClosePopup = main.ClosePopup - main.OpenPopup = function(_, _, _, _, controls) - capturedControls = controls - end - main.ClosePopup = function() end - local callbackCount = 0 - local ok, errMsg = pcall(function() - local tradeQuery = new("TradeQuery"):TradeQuery({}) - tradeQuery:SetStatWeights(nil, function() - callbackCount = callbackCount + 1 - end) - capturedControls.reset.onClick() - capturedControls.finalise.onClick() + local tradeQuery = new("TradeQuery"):TradeQuery({}) + tradeQuery:SetStatWeights(nil, function() + callbackCount = callbackCount + 1 end) - main.OpenPopup = originalOpenPopup - main.ClosePopup = originalClosePopup + capturedControls.reset.onClick() + capturedControls.finalise.onClick() - assert.is_true(ok, errMsg) assert.are.equal(1, callbackCount) end) end) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 2cb385bbe60..b35c8deb010 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -12,25 +12,19 @@ describe("WeightedScore module", function() data.misc.maxStatIncrease = savedMaxStatIncrease end) - -- defaultWeights ----------------------------------------------------------- + it("provides default and configured weights", function() + local defaults = WeightedScore.defaultWeights() + assert.are.equal(2, #defaults) + assert.are.equal("FullDPS", defaults[1].stat) + assert.are.equal("TotalEHP", defaults[2].stat) - it("defaultWeights returns two entries (FullDPS and TotalEHP)", function() - local weights = WeightedScore.defaultWeights() - assert.are.equal(2, #weights) - assert.are.equal("FullDPS", weights[1].stat) - assert.are.equal("TotalEHP", weights[2].stat) - end) - - -- getWeights --------------------------------------------------------------- - - it("getWeights selects defaults or configured weights", function() local custom = { { stat = "TotalDPS", label = "DPS", weightMult = 2.0 } } local cases = { - { label = "nil build", expected = WeightedScore.defaultWeights() }, + { label = "nil build", expected = defaults }, { label = "empty selection", build = { itemsTab = { tradeQuery = { statSortSelectionList = {} } } }, - expected = WeightedScore.defaultWeights(), + expected = defaults, }, { label = "configured selection", @@ -44,14 +38,12 @@ describe("WeightedScore module", function() end end) - -- computeRatioScore: basic ranking ----------------------------------------- - - it("scores ordinary ratios and weight combinations", function() + it("scores ratios, weighted combinations, and invalid inputs", function() local unitWeight = { { stat = "TotalDPS", weightMult = 1.0 } } local cases = { - { label = "neutral", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1000 }, weights = unitWeight, expected = 1.0 }, - { label = "better", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1500 }, weights = unitWeight, expected = 1.5 }, - { label = "worse", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 500 }, weights = unitWeight, expected = 0.5 }, + { label = "neutral", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1000 }, expected = 1.0 }, + { label = "better", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1500 }, expected = 1.5 }, + { label = "worse", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 500 }, expected = 0.5 }, { label = "multiple weighted stats", base = { TotalDPS = 100, TotalEHP = 200 }, @@ -63,22 +55,6 @@ describe("WeightedScore module", function() expected = 2.5625, }, { label = "empty weights", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 5000 }, weights = {}, expected = 0.0 }, - } - - for _, case in ipairs(cases) do - assert.are.equal( - case.expected, - WeightedScore.computeRatioScore(case.base, case.candidate, case.weights), - case.label - ) - end - end) - - -- computeRatioScore: edge cases -------------------------------------------- - - it("handles infinite, zero, and missing ratio inputs safely", function() - local weights = { { stat = "TotalDPS", weightMult = 1.0 } } - local cases = { { label = "infinite base", base = { TotalDPS = math.huge }, candidate = { TotalDPS = 1000 }, expected = 0.0 }, { label = "infinite candidate", base = { TotalDPS = 1000 }, candidate = { TotalDPS = math.huge }, expected = 2.0 }, { label = "zero base", base = { TotalDPS = 0 }, candidate = { TotalDPS = 500 }, expected = 2.0 }, @@ -88,33 +64,31 @@ describe("WeightedScore module", function() for _, case in ipairs(cases) do assert.are.equal( case.expected, - WeightedScore.computeRatioScore(case.base, case.candidate, weights), + WeightedScore.computeRatioScore(case.base, case.candidate, case.weights or unitWeight), case.label ) end end) - -- computeRatioScore: FullDPS fallback -------------------------------------- - - it("uses combined DPS fallback when FullDPS is absent from both outputs", function() - -- baseSum = 500+200+300 = 1000, newSum = 750+300+450 = 1500 → ratio 1.5 - local base = { TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 } - local new = { TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 } - local weights = { { stat = "FullDPS", weightMult = 1.0 } } - assert.are.equal(1.5, WeightedScore.computeRatioScore(base, new, weights)) - end) - - it("does not activate fallback when FullDPS is present (no double-counting)", function() - -- If fallback also ran, score would be higher than 1.5 (the FullDPS ratio) - local base = { FullDPS = 1000, TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 } - local new = { FullDPS = 1500, TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 } + it("uses combined DPS only when FullDPS is absent", function() + local cases = { + { + label = "fallback", + base = { TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 }, + candidate = { TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 }, + }, + { + label = "direct FullDPS", + base = { FullDPS = 1000, TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 }, + candidate = { FullDPS = 1500, TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 }, + }, + } local weights = { { stat = "FullDPS", weightMult = 1.0 } } - -- Only FullDPS direct: 1500/1000 = 1.5 - assert.are.equal(1.5, WeightedScore.computeRatioScore(base, new, weights)) + for _, case in ipairs(cases) do + assert.are.equal(1.5, WeightedScore.computeRatioScore(case.base, case.candidate, weights), case.label) + end end) - -- weightsRequireFullDPS: FullDPS requirement used by PowerBuilder ---------- - it("weightsRequireFullDPS identifies active FullDPS weights", function() local cases = { { label = "nil", expected = false }, @@ -150,7 +124,7 @@ describe("WeightedScore — TradeQueryGenerator delegation", function() GetTradeStatusOption = function() return "online" end, }) - it("WeightedRatioOutputs delegates ratio calculation and preserves candidate ranking", function() + it("WeightedRatioOutputs delegates ratio calculation", function() local savedMax = data.misc.maxStatIncrease data.misc.maxStatIncrease = 2 @@ -163,15 +137,6 @@ describe("WeightedScore — TradeQueryGenerator delegation", function() data.misc.maxStatIncrease = savedMax assert.are.equal(direct, delegated) - - base = { TotalDPS = 1000 } - local high = { TotalDPS = 1500 } - local low = { TotalDPS = 800 } - weights = { { stat = "TotalDPS", weightMult = 1.0 } } - - local highScore = mock_queryGen.WeightedRatioOutputs(base, high, weights) - local lowScore = mock_queryGen.WeightedRatioOutputs(base, low, weights) - assert.is_true(highScore > lowScore) end) end) @@ -188,13 +153,14 @@ describe("WeightedScore — tree integration", function() local function drainPowerBuild(stat) build.calcsTab.powerBuildFlag = true - build.calcsTab.powerStat = stat or findStat("Life") - local maxIterations = 100000 + build.calcsTab.powerStat = stat + build.calcsTab.nodePowerMaxDepth = 1 local iterations = 0 repeat build.calcsTab:BuildPower() iterations = iterations + 1 - until not build.calcsTab.powerBuilder or iterations >= maxIterations + until not build.calcsTab.powerBuilder or iterations >= 1000 + assert.is_nil(build.calcsTab.powerBuilder, "depth-1 Power Builder must finish within 1000 steps") end it("registers WeightedScore as the final shared non-minion power stat", function() @@ -218,9 +184,7 @@ describe("WeightedScore — tree integration", function() assert.is_not_nil(stat) local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator - local originalNodePowerMaxDepth = build.calcsTab.nodePowerMaxDepth local calledUseFullDPS = { } - build.calcsTab.nodePowerMaxDepth = 1 build.calcsTab.GetMiscCalculator = function() local function calcFunc(_, useFullDPS) calledUseFullDPS[#calledUseFullDPS + 1] = useFullDPS @@ -241,20 +205,15 @@ describe("WeightedScore — tree integration", function() } end - local ok, errMsg = pcall(function() - drainPowerBuild(stat) - end) + drainPowerBuild(stat) build.calcsTab.GetMiscCalculator = originalGetMiscCalculator - build.calcsTab.nodePowerMaxDepth = originalNodePowerMaxDepth - assert.is_true(ok, errMsg) assert.is_true(#calledUseFullDPS > 0, "fixture should exercise candidate calculations") for _, useFullDPS in ipairs(calledUseFullDPS) do assert.is_true(useFullDPS) end end) - -- Fallback weights must evaluate the configured stats rather than a synthetic output key. it("power stat helpers keep positive WeightedScore deltas and baselines on the same scale", function() local weightedScore = findStat("WeightedScore") local fullDPS = findStat("FullDPS") @@ -310,7 +269,6 @@ describe("WeightedScore — tree integration", function() assert.is_true(score ~= 0) end) - -- appendEditWeightsAction ----------------------------------------------- local function buildWithWeightEditor(openEditor) return { itemsTab = { tradeQuery = { SetStatWeights = function(_, previousSelection, onSave) From dfb31ff507590536cab0a0dff9b9db370a8630b8 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 23 Aug 2026 21:15:53 +0200 Subject: [PATCH 34/37] Preserve upstream power report filtering Keep the allocated-node filter and its tests on the dedicated Power Report branch. --- src/Classes/PowerReportListControl.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Classes/PowerReportListControl.lua b/src/Classes/PowerReportListControl.lua index 69738aac11f..6472b01eeb2 100644 --- a/src/Classes/PowerReportListControl.lua +++ b/src/Classes/PowerReportListControl.lua @@ -112,6 +112,8 @@ function PowerReportListClass:ReList() end if self.allocated then insert = item.allocated + elseif item.allocated then + insert = false end if not self.showMasteries and item.type == "Mastery" then insert = false From aeb8f7e58d6d1ee2f66e58c9c6e99603d0905aae Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 28 Aug 2026 23:55:22 +0200 Subject: [PATCH 35/37] Strengthen Weighted Score FullDPS fallback test Use divergent direct and fallback ratios so the contract fails if FullDPS branch selection regresses. --- spec/System/TestWeightedScore_spec.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index b35c8deb010..8e5b489f038 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -76,16 +76,18 @@ describe("WeightedScore module", function() label = "fallback", base = { TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 }, candidate = { TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 }, + expected = 1.5, }, { label = "direct FullDPS", base = { FullDPS = 1000, TotalDPS = 500, TotalDotDPS = 200, CombinedDPS = 300 }, - candidate = { FullDPS = 1500, TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 }, + candidate = { FullDPS = 1250, TotalDPS = 750, TotalDotDPS = 300, CombinedDPS = 450 }, + expected = 1.25, }, } local weights = { { stat = "FullDPS", weightMult = 1.0 } } for _, case in ipairs(cases) do - assert.are.equal(1.5, WeightedScore.computeRatioScore(case.base, case.candidate, weights), case.label) + assert.are.equal(case.expected, WeightedScore.computeRatioScore(case.base, case.candidate, weights), case.label) end end) From ddd2a86977da65550cc56545a684f9c715c2f617 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 27 Sep 2026 10:06:03 +0200 Subject: [PATCH 36/37] Check weighted scores exactly for the current build Add the current-build case to the existing exact-score test and remove the weaker non-zero check. --- spec/System/TestWeightedScore_spec.lua | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 8e5b489f038..8645d6f452e 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -120,13 +120,13 @@ describe("WeightedScore module", function() end) end) -describe("WeightedScore — TradeQueryGenerator delegation", function() +describe("WeightedScore — TradeQueryGenerator scoring", function() local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {}, GetTradeStatusOption = function() return "online" end, }) - it("WeightedRatioOutputs delegates ratio calculation", function() + it("WeightedRatioOutputs preserves every configured weight", function() local savedMax = data.misc.maxStatIncrease data.misc.maxStatIncrease = 2 @@ -229,8 +229,12 @@ describe("WeightedScore — tree integration", function() local baseValue = data.powerStatList.GetValue(baseOutput, weightedScore, build, baseOutput) local betterValue = data.powerStatList.GetValue(betterOutput, weightedScore, build, baseOutput) local delta = build.calcsTab:CalculatePowerStat(weightedScore, betterOutput, baseOutput) + local calcFunc = build.calcsTab:GetMiscCalculator(build) + local currentOutput = calcFunc() + local currentValue = data.powerStatList.GetValue(currentOutput, weightedScore, build) assert.is_true(math.abs(baseValue - 1500) < 0.0001) assert.is_true(math.abs(betterValue - 1700) < 0.0001) + assert.is_true(math.abs(currentValue - 1500) < 0.0001) assert.is_true(betterValue > baseValue) assert.is_true(math.abs(delta - 200) < 0.0001) assert.is_true(math.abs(delta / baseValue - 2 / 15) < 0.0001) @@ -262,15 +266,6 @@ describe("WeightedScore — tree integration", function() assert.is_true(score > 0) end) - it("getValue on WeightedScore entry returns non-zero score for current build output", function() - local stat = findStat("WeightedScore") - assert.is_not_nil(stat) - local calcFunc = build.calcsTab:GetMiscCalculator(build) - local baseOutput = calcFunc() - local score = stat.getValue(baseOutput, build) - assert.is_true(score ~= 0) - end) - local function buildWithWeightEditor(openEditor) return { itemsTab = { tradeQuery = { SetStatWeights = function(_, previousSelection, onSave) From 53ece6ff1600f4423a986f45b3295d9192ab7d54 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 27 Sep 2026 14:11:35 +0200 Subject: [PATCH 37/37] Keep Weighted Score sorting in Add Implicit Carry the existing Weighted Score path across the upstream Add Implicit extraction. Use the displayed item as the baseline and rerank the list after weight edits. --- src/Modules/AddImplicitPopup.lua | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Modules/AddImplicitPopup.lua b/src/Modules/AddImplicitPopup.lua index b324c47cddb..4edc898e9d1 100644 --- a/src/Modules/AddImplicitPopup.lua +++ b/src/Modules/AddImplicitPopup.lua @@ -1,5 +1,6 @@ local m_max = math.max local t_insert = table.insert +local WeightedScore = require("Modules.WeightedScore") local M = {} @@ -209,7 +210,7 @@ function M.AddImplicitToDisplayItem(itemsTab, displayItem) t_insert(item.implicitModLines, modLine) end end - local function getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) listMod.sortValues = listMod.sortValues or {} if listMod.sortValues[stat] ~= nil then return listMod.sortValues[stat] @@ -219,7 +220,7 @@ function M.AddImplicitToDisplayItem(itemsTab, displayItem) applyCandidateMod(item, listMod) item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, itemsTab.build, calcBase) listMod.sortValues[stat] = value return value end @@ -230,12 +231,14 @@ function M.AddImplicitToDisplayItem(itemsTab, displayItem) local selectedGroup = not selectFirst and modGroups[controls.modGroupSelect.selIndex] or nil local selectedMod = not selectFirst and controls.modSelect.list and controls.modSelect.list[controls.modSelect.selIndex] or nil if stat then + local statEntry = sortStats[stat] local slotName = displayItem:GetPrimarySlot() local calcFunc = itemsTab.build.calcsTab:GetMiscCalculator() - local useFullDPS = stat == "FullDPS" + local useFullDPS = data.powerStatList.RequiresFullDPS(statEntry, itemsTab.build) + local calcBase = calcFunc({ repSlotName = slotName, repItem = displayItem }, useFullDPS) for _, listMods in ipairs(modList) do for _, listMod in ipairs(listMods) do - listMod.sortValue = getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + listMod.sortValue = getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(listMods, function(a, b) if a.sortValue ~= b.sortValue then @@ -293,6 +296,17 @@ function M.AddImplicitToDisplayItem(itemsTab, displayItem) controls.modSelect:SetSel(1, true) end end + local function clearSortValues() + for _, group in ipairs(modGroups) do + group.sortValue = nil + end + for _, listMods in ipairs(modList) do + for _, listMod in ipairs(listMods) do + listMod.sortValue = nil + listMod.sortValues = nil + end + end + end local function addModifier() local item = new("Item"):Item(displayItem:BuildRaw()) item.id = displayItem.id @@ -327,9 +341,8 @@ function M.AddImplicitToDisplayItem(itemsTab, displayItem) controls.sortLabel.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end - controls.sort = new("DropDownControl"):DropDownControl({ "TOPLEFT", nil, "TOPLEFT" }, { 355, 20, 240, 18 }, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("DropDownControl"):DropDownControl({ "TOPLEFT", nil, "TOPLEFT" }, { 355, 20, 240, 18 }, sortList, + WeightedScore.createSortHandler(sortList, controls, itemsTab.build, applySort, clearSortValues)) controls.sort.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end