From dd9af0809cad711e1bb5cf48dbbf53b44b6fc995 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:24 +0100 Subject: [PATCH 01/31] feat(weighted-score): add shared WeightedScore module and integrate into tree/items - Add Modules/WeightedScore with defaultWeights(), getWeights(), computeRatioScore() - Register WeightedScore entry in data.powerStatList (isWeightedScore flag) - TradeQueryGenerator.WeightedRatioOutputs delegates to WeightedScore.computeRatioScore - TradeQuery.SetStatWeights: add onSave callback, filter isWeightedScore from stat list - CalcsTab.CalculatePowerStat: isWeightedScore branch for heatmap scoring - TreeTab: add Edit Weights... button (shown only when WeightedScore heatmap active) - ItemDBControl: add WeightedScore sort mode and Edit Weights... button 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 7af7c72e63c..e2f7285b010 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 609aa5a3aa9..9f83fe6cfe4 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") @@ -44,6 +44,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) @@ -216,6 +224,7 @@ function ItemDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -240,7 +249,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 7ea2fcedf00..c12efc6c8e9 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -666,7 +666,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 = { } @@ -681,7 +681,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 = { @@ -749,6 +749,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 9b7e3f5b406..e5fde327c6b 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 @@ -168,40 +169,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 c9cb48c2111..da10cc203f8 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) @@ -463,6 +476,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) @@ -1045,6 +1059,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 30202c4609b..fc94a6813cd 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -176,6 +176,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 1c227864dd7a3fd85ae302be1a2ceb3b3e6d1515 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:32 +0100 Subject: [PATCH 02/31] test(weighted-score): add WeightedScore test coverage 19 tests across 3 describe blocks: - WeightedScore module: defaultWeights, getWeights, computeRatioScore (neutrality, ranking, edge cases: inf/zero/missing, FullDPS fallback) - TradeQueryGenerator delegation: result matches direct call, ranking preserved - Tree integration: stat registered, power builder completes, powerMax >= 0 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 76da1ab9e3457dbe90c5d8b4448924e9d8b64a58 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 22 Mar 2026 19:00:45 +0100 Subject: [PATCH 03/31] feat(weighted-score): add WeightedScore sort support to anoint panel NotableDBControl was missing the isWeightedScore propagation in BuildSortOrder and the computeRatioScore branch in ListBuilder, causing all notables to score 0 when sorted by Weighted Score. 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 4216c22be6b..ec120ac2e8d 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 @@ -98,6 +99,7 @@ function NotableDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -134,12 +136,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 d131886c59cde5aa172a4008cc0d1fc6d20b0b48 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 00:00:00 +0100 Subject: [PATCH 04/31] feat(weighted-score): add getValue to WeightedScore powerStatList entry Consumers of powerStatList that need to compute a stat value from a calc output (e.g. RadiusJewelFinder's getImpactValue) cannot read output["WeightedScore"] directly since it is not a real calc field. Provide a getValue(output, build) callback on the stat entry so any consumer can get a meaningful weighted ratio score without needing to know about the WeightedScore module. 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 fc94a6813cd..924a057bda8 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -176,7 +176,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 7269245f7ac011d44b75276d41b6b224ef986b9c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 23:11:33 +0100 Subject: [PATCH 05/31] fix(weighted-score): cache WeightedScore module load in Data.lua MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadModule does not cache — calling it inside getValue on every invocation created a new module table each time, causing memory pressure in tight loops like RadiusJewelFinder compute. Move the LoadModule call to module scope so it executes once at startup. 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 924a057bda8..b8c8fbc0cab 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 @@ -177,7 +178,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 693a2a221486e26cf196d97a8f8112e1f740b1a7 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:18:29 +0100 Subject: [PATCH 06/31] fix(weighted-score): support getValue in generateFallbackWeights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When 'Weighted Score' was selected as Fallback Weight Mode, the timeless jewel finder's generateFallbackWeights read output["WeightedScore"] — a field that does not exist in calc output. This returned 0 for every node, causing all fallback weights to be computed as -100 (or -50 for nodes with a non-unit divisor), making the weighted score sort meaningless. Fix: introduce a getStatValue helper inside generateFallbackWeights that delegates to selection.getValue(rawOutput, build) when present, mirroring the same pattern used in RadiusJewelFinder:getImpactValue. The raw (non-Minion-scoped) output is passed so getValue receives the full calc output as expected by WeightedScore.computeRatioScore. Also guard baseValue == 0 to avoid division by zero for builds with no relevant output stat. Add two tests to TestWeightedScore_spec.lua covering getValue correctness on the powerStatList entry. 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 da10cc203f8..1304ba8103e 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1949,7 +1949,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 @@ -1957,7 +1963,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 22ba8ed5a18dd425dabeffbb6968a35590c1ff77 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:38:23 +0100 Subject: [PATCH 07/31] fix(weighted-score): propagate getValue into fallbackWeightsList entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallbackWeightsList dropdown entries were built by copying only stat, transform, and label from data.powerStatList — getValue was silently dropped. As a result, generateFallbackWeights received selection.getValue = nil and could never delegate to the WeightedScore callback, falling back to reading output["WeightedScore"] (a non-existent field) and producing weight = -100 for every node. Fix: copy getValue when building each fallbackWeightsList entry so the callback reaches generateFallbackWeights correctly. Add a test asserting that the constructed entry carries getValue. 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 1304ba8103e..a66dc19e326 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -2127,6 +2127,7 @@ function TreeTabClass:FindTimelessJewel() label = "Sort by " .. stat.label, stat = stat.stat, transform = stat.transform, + getValue = stat.getValue, }) end end From f65895196f420b4cef1e21d6e06a0951a875bee5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 28 Mar 2026 11:33:00 +0100 Subject: [PATCH 08/31] fix(trade): initialize stat weights before opening editor --- src/Classes/TradeQuery.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index c12efc6c8e9..025a8acb7dd 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -668,6 +668,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 d5dd43c050543c6b98dc1de0b46f2fe072b4e0cb Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 9 May 2026 09:45:19 +0200 Subject: [PATCH 09/31] feat(weighted-score): show weighted score in Power Report Route WeightedScore power reports through FullDPS when active weights require it, reuse the cached base output for WeightedScore getValue, and keep allocated nodes out of the default unallocated report list. --- spec/System/TestPowerReport_spec.lua | 38 +++++++++ spec/System/TestWeightedScore_spec.lua | 108 +++++++++++++++++++++++++ src/Classes/CalcsTab.lua | 6 +- src/Classes/PowerReportListControl.lua | 2 + src/Modules/Data.lua | 8 +- src/Modules/WeightedScore.lua | 14 ++++ 6 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 spec/System/TestPowerReport_spec.lua diff --git a/spec/System/TestPowerReport_spec.lua b/spec/System/TestPowerReport_spec.lua new file mode 100644 index 00000000000..1a7d7220888 --- /dev/null +++ b/spec/System/TestPowerReport_spec.lua @@ -0,0 +1,38 @@ +describe("PowerReportListControl", function() + local PowerReportListControl + + before_each(function() + LoadModule("Classes/PowerReportListControl") + PowerReportListControl = common.classes.PowerReportListControl + end) + + local function relist(originalList, showClusters, allocated) + local control = { + originalList = originalList, + showClusters = showClusters or false, + allocated = allocated or false, + } + PowerReportListControl.ReList(control) + return control.list + end + + it("Show Unallocated excludes allocated nodes", function() + local list = relist({ + { name = "allocated", power = 10, pathDist = 1, allocated = true }, + { name = "unallocated", power = 5, pathDist = 1, allocated = false }, + }, false, false) + + assert.are.equal(1, #list) + assert.are.equal("unallocated", list[1].name) + end) + + it("Show Allocated includes allocated nodes", function() + local list = relist({ + { name = "allocated", power = 10, pathDist = 1, allocated = true }, + { name = "unallocated", power = 5, pathDist = 1, allocated = false }, + }, false, true) + + assert.are.equal(1, #list) + assert.are.equal("allocated", list[1].name) + end) +end) 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 e2f7285b010..b4839474bb1 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/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 diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index b8c8fbc0cab..b23fd6d707e 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -177,9 +177,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 78a581f1fad3ce0949036ffe5146c55d22f7fbe5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 10 May 2026 08:23:01 +0200 Subject: [PATCH 10/31] refactor(weighted-score): unify Edit Weights affordance via dropdown action entry Replace per-surface Edit Weights buttons (TreeTab heatmap, ItemDB unique sort) with a shared action entry appended to any sort/heatmap dropdown that exposes Weighted Score. NotableDB anoint sort gains the same affordance (no Edit Weights button before). Why: the ItemDB pane is 360 px wide; the previous Edit Weights button shared the same anchor as the League dropdown and forced a mutually exclusive show/hide between League filter and Edit Weights. A user who wanted to filter by League and sort by Weighted Score could not do both. WeightedScore.appendEditWeightsAction(list, openEditor) sentinel-checks for any entry with isWeightedScore and appends a single action entry. Each consumer dropdown selFunc gates on value.isAction: invokes value.action() then restores the prior selection via SelByValue. Tests: 2 new specs in TestWeightedScore_spec (no-op without WS, append plus invocable callback when WS present). Peer-reviewed by Codex (approved, no blocking findings). --- 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 9f83fe6cfe4..ae8b98c1e27 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -39,19 +39,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) @@ -228,6 +225,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 ec120ac2e8d..43e456a50f2 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -36,7 +36,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 @@ -103,6 +108,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 @@ -297,4 +308,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 a66dc19e326..33e599181d5 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) @@ -476,7 +477,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) @@ -1059,7 +1059,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 6abb4463d141efc8940eb13c869ba42bfd6eaab1 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 20:54:14 +0200 Subject: [PATCH 11/31] fix(weighted-score): preserve output stat semantics Restore the shared accessor, transforms, and FullDPS fallback expected by Trade Query after the origin/dev rebase. --- 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 4d6aa30f661bee7d805ca32a0bb28b57d5fdd3fa Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 20:57:47 +0200 Subject: [PATCH 12/31] fix(weighted-score): limit sorting to supported surfaces Avoid generating a Minion Weighted Score entry and hide the score from modifier sort menus that only read 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 3ae6e046aa3..ad139ae8b77 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -70,7 +70,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 b23fd6d707e..42c7412de1a 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -233,7 +233,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 4d49bf933faa063de9366420592c5c8229b15d36 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:02:18 +0200 Subject: [PATCH 13/31] fix(weighted-score): preserve Item DB ranking Keep negative scores and request FullDPS 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 ae8b98c1e27..13d6db1be56 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -256,11 +256,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 bbe974eea521db1eea3f0c78fc5378eb2d1457a8 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:10:34 +0200 Subject: [PATCH 14/31] fix(weighted-score): preserve comparison context Calculate Weighted Score candidates with the matching Full DPS context and retain the baseline output for Tree 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 eca72619032..93b3aeff213 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -15,6 +15,7 @@ local calcsHelpers = LoadModule("Classes/CompareCalcsHelpers") local buildListHelpers = LoadModule("Modules/BuildListHelpers") local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") local configVisibility = LoadModule("Modules/ConfigVisibility") +local WeightedScore = LoadModule("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 @@ -2491,10 +2492,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 @@ -2597,7 +2603,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) @@ -2894,7 +2901,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 @@ -2977,7 +2985,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 @@ -3046,7 +3055,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 33e599181d5..cc2aff4b481 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1947,10 +1947,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 @@ -1961,7 +1966,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 699de1f0aa3aa62adc8cc1599e98861cf4593967 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:13:44 +0200 Subject: [PATCH 15/31] test(weighted-score): cover combined stat weights --- 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 31fe642c9413b276bf88a6c933c58797fc1d3696 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:16:50 +0200 Subject: [PATCH 16/31] refactor(weighted-score): share default 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 025a8acb7dd..85469e51c74 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") +local WeightedScore = LoadModule("Modules/WeightedScore") local get_time = os.time local t_insert = table.insert @@ -254,23 +255,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 6ab3c64e914e6d8a7b9a698f1e3f4c1d8c5d99d5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:24:08 +0200 Subject: [PATCH 17/31] fix(weighted-score): 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 43e456a50f2..5c230ac0df8 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -146,13 +146,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 86a3bc4ee331465ac02ce3fcf1a614d7642eeca3 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:26:12 +0200 Subject: [PATCH 18/31] fix(weighted-score): persist 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 9a83a331c4f..59c4ba20835 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -114,4 +114,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 85469e51c74..a1fd697b985 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -751,6 +751,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 0e9c4e1d5305dd8683b0a364eeb7e657b55dec4c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 09:17:11 +0200 Subject: [PATCH 19/31] fix(weighted-score): place 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 42c7412de1a..77c9615fe55 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -177,15 +177,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 @@ -242,6 +233,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 3b093e27447892cca5095bf979bd30c5900952b9 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 09:30:47 +0200 Subject: [PATCH 20/31] fix(weighted-score): place weight editor after 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 3f90e45d1904ab805b2f318f8b6404511efe31b4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 11:18:41 +0200 Subject: [PATCH 21/31] feat(weighted-score): sort item modifiers by 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 | 138 +++++++++++++++++-------- src/Modules/Data.lua | 27 ++++- src/Modules/WeightedScore.lua | 19 ++++ 4 files changed, 200 insertions(+), 44 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 ad139ae8b77..ece265bbafd 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -15,6 +15,7 @@ local m_ceil = math.ceil local m_floor = math.floor local m_modf = math.modf local buySimilar = LoadModule("Classes/CompareBuySimilar") +local WeightedScore = LoadModule("Modules/WeightedScore") local gemTooltip = LoadModule("Classes/GemTooltip") local rarityDropList = { @@ -70,14 +71,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 local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Control") @@ -2755,7 +2764,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] @@ -2777,7 +2786,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 @@ -2787,11 +2796,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 @@ -2816,6 +2824,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) @@ -2850,9 +2864,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) @@ -3065,7 +3082,7 @@ function ItemsTabClass:CorruptDisplayItem(modType) end control:SelByValue(selfMod, "mod") 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] @@ -3086,17 +3103,17 @@ function ItemsTabClass:CorruptDisplayItem(modType) 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 @@ -3114,14 +3131,17 @@ function ItemsTabClass:CorruptDisplayItem(modType) 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 == "Corrupted" then - sortModType("Corrupted", stat, calcFunc, slotName, useFullDPS) + sortModType("Corrupted", 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 == "Corrupted" then buildList(controls.implicit1, controls.implicit2, currentModType) @@ -3137,6 +3157,14 @@ function ItemsTabClass:CorruptDisplayItem(modType) if controls.implicit3 then controls.implicit3:UpdateSearch() end if controls.implicit4 then controls.implicit4: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 @@ -3285,12 +3313,15 @@ function ItemsTabClass:CorruptDisplayItem(modType) controls.implicit2:SetSel(1) controls.implicit3:SetSel(1) controls.implicit4:SetSel(1) - end) + 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") @@ -3355,7 +3386,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] @@ -3367,7 +3398,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 @@ -3377,11 +3408,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 @@ -3406,6 +3436,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 ---Mutates modList to contain mods from the specified source ---@param sourceId string @The crafting source id to build the list of mods for local function buildMods(sourceId) @@ -3622,9 +3658,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 @@ -3956,7 +3995,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() t_insert(item.implicitModLines, { line = line, modTags = listMod.mod.modTags, [listMod.type] = true }) 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] @@ -3966,7 +4005,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() 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, self.build, calcBase) listMod.sortValues[stat] = value return value end @@ -3977,12 +4016,11 @@ function ItemsTabClass:AddImplicitToDisplayItem() 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 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 _, 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 @@ -4040,6 +4078,17 @@ function ItemsTabClass:AddImplicitToDisplayItem() 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(self.displayItem:BuildRaw()) item.id = self.displayItem.id @@ -4076,9 +4125,12 @@ function ItemsTabClass:AddImplicitToDisplayItem() 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 77c9615fe55..a7379f5d818 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -129,7 +129,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" }, @@ -211,6 +211,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, @@ -237,6 +259,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 5f4076649d082164860bbb41b712c8f3aea73280 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 6 Aug 2026 12:42:59 +0200 Subject: [PATCH 22/31] fix(weighted-score): preserve refresh callback after reset Forward the callback when Reset reopens the editor so the subsequent 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 59c4ba20835..c64ddfc725c 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -144,5 +144,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 a1fd697b985..b3b1f5b8ee5 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -774,7 +774,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 fc8f53094aabef34bc8f1932adbacacc2bba7808 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 6 Aug 2026 23:04:36 +0200 Subject: [PATCH 23/31] 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 ece265bbafd..688ce123bda 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -2234,6 +2234,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 @@ -2241,6 +2242,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 @@ -2269,9 +2272,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 @@ -2283,9 +2288,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 37efced90b1503e4db58c772aba711882b629b20 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 11:07:41 +0200 Subject: [PATCH 24/31] 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 4ec1714b46e..556aa765c47 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 93b3aeff213..b9af5c583ae 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 688ce123bda..6f13136a16c 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -663,6 +663,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 @@ -732,8 +741,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 cc2aff4b481..77880273e42 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1947,8 +1947,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 @@ -2132,14 +2131,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 c7fe989452267b7140b706f09013dac5e3a7839d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 15 Aug 2026 17:06:48 +0200 Subject: [PATCH 25/31] 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 c64ddfc725c..51a4daaf2f6 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -126,7 +126,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 @@ -156,7 +156,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 051f4cb49ea9c880bf975b0af8466f89eccbf7d4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 17:20:27 +0200 Subject: [PATCH 26/31] 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 | 6 ++- src/Modules/WeightedScore.lua | 8 ++-- 13 files changed, 89 insertions(+), 147 deletions(-) diff --git a/spec/System/TestAbyssTimelessJewel_spec.lua b/spec/System/TestAbyssTimelessJewel_spec.lua index 556aa765c47..0ba84adbd77 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 b4839474bb1..d184072546d 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 b9af5c583ae..5e117e83252 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 @@ -2505,15 +2505,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 @@ -2616,8 +2618,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) @@ -2913,10 +2914,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) @@ -2997,10 +2995,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) @@ -3068,8 +3063,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 13d6db1be56..0531dc79463 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -39,7 +39,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 @@ -215,14 +215,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() @@ -252,37 +248,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 6f13136a16c..a5956270755 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -72,7 +72,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 @@ -742,7 +742,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 5c230ac0df8..de6a3045a01 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -36,7 +36,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 @@ -98,14 +98,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() @@ -127,6 +123,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 @@ -146,20 +145,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 b3b1f5b8ee5..8d9177b89e6 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -687,7 +687,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 77880273e42..08af1e5bc75 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") @@ -1953,10 +1953,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 @@ -2126,14 +2123,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 @@ -2149,7 +2141,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 a7379f5d818..391e20b9bd2 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -124,6 +124,9 @@ end ---@field ignoreForNodes? boolean ---@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 StatTable[] data.powerStatList = { @@ -246,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) @@ -258,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 b01620698f57e27e4943c8ade248d8ea1a294e62 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 17:49:17 +0200 Subject: [PATCH 27/31] 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 391e20b9bd2..07cda254c3b 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 ad7bf52ec32a1c3038e889a59bcbf8863ce9d007 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 19:32:44 +0200 Subject: [PATCH 28/31] 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 | 32 ++++++-------------------- src/Classes/NotableDBControl.lua | 5 +--- src/Classes/TreeTab.lua | 10 ++------ src/Modules/WeightedScore.lua | 11 +++++++-- 7 files changed, 35 insertions(+), 53 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 5e117e83252..fa3ed7b645e 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 0531dc79463..b721ed07c02 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -222,10 +222,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 a5956270755..fcb19bfe38d 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -666,11 +666,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() @@ -2886,11 +2884,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) @@ -3338,11 +3332,7 @@ function ItemsTabClass:CorruptDisplayItem(modType) 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") @@ -3680,11 +3670,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 @@ -4147,11 +4133,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() 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 de6a3045a01..f81df3bd706 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -105,10 +105,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 08af1e5bc75..c16a016bc8c 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 @@ -2135,10 +2132,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 198fd2f816d06e640bd3f2def2ae4630240193d4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 20 Aug 2026 20:14:52 +0200 Subject: [PATCH 29/31] 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 8ece022852361605216d5dbfc3b30b6772c48011 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 21 Aug 2026 10:21:19 +0200 Subject: [PATCH 30/31] Extract Power Report fix from weighted score Keep the generic allocated-node filter and its regression coverage on the dedicated Power Report bugfix branch. --- spec/System/TestPowerReport_spec.lua | 38 -------------------------- src/Classes/PowerReportListControl.lua | 2 -- 2 files changed, 40 deletions(-) delete mode 100644 spec/System/TestPowerReport_spec.lua diff --git a/spec/System/TestPowerReport_spec.lua b/spec/System/TestPowerReport_spec.lua deleted file mode 100644 index 1a7d7220888..00000000000 --- a/spec/System/TestPowerReport_spec.lua +++ /dev/null @@ -1,38 +0,0 @@ -describe("PowerReportListControl", function() - local PowerReportListControl - - before_each(function() - LoadModule("Classes/PowerReportListControl") - PowerReportListControl = common.classes.PowerReportListControl - end) - - local function relist(originalList, showClusters, allocated) - local control = { - originalList = originalList, - showClusters = showClusters or false, - allocated = allocated or false, - } - PowerReportListControl.ReList(control) - return control.list - end - - it("Show Unallocated excludes allocated nodes", function() - local list = relist({ - { name = "allocated", power = 10, pathDist = 1, allocated = true }, - { name = "unallocated", power = 5, pathDist = 1, allocated = false }, - }, false, false) - - assert.are.equal(1, #list) - assert.are.equal("unallocated", list[1].name) - end) - - it("Show Allocated includes allocated nodes", function() - local list = relist({ - { name = "allocated", power = 10, pathDist = 1, allocated = true }, - { name = "unallocated", power = 5, pathDist = 1, allocated = false }, - }, false, true) - - assert.are.equal(1, #list) - assert.are.equal("allocated", list[1].name) - end) -end) 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 22b2edd907bdfc730aa9d246c0db80c85a407009 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 21 Aug 2026 10:56:29 +0200 Subject: [PATCH 31/31] 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 c16a016bc8c..79e6a8c1164 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -2125,26 +2125,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 07cda254c3b..565b20aa90c 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()