From fb3c1098a6a3e0b3ac9ddf2a2a6c81f5eab4f741 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:24 +0100 Subject: [PATCH 01/21] 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 | 38 ++---------------- src/Classes/TreeTab.lua | 16 ++++++++ src/Modules/Data.lua | 1 + src/Modules/WeightedScore.lua | 60 +++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 39 deletions(-) create mode 100644 src/Modules/WeightedScore.lua diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index f621c7de751..20994b751bd 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" }, @@ -733,6 +734,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 f8a56af8282..ac9c2bc3628 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") local ItemDBClass = newClass("ItemDBControl", "ListControl", function(self, anchor, rect, itemsTab, db, dbType) self.ListControl(anchor, rect, 16, "VERTICAL", false) @@ -41,6 +41,14 @@ local ItemDBClass = newClass("ItemDBControl", "ListControl", function(self, anch self.controls.league = new("DropDownControl", {"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, self.leagueList, function(index, value) self.listBuildFlag = true end) + self.controls.editWeights = new("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", {"LEFT",self.controls.sort,"BOTTOMLEFT"}, {0, 11, 179, 18}, { "Any requirements", "Current level", "Current attributes", "Current useable" }, function(index, value) self.listBuildFlag = true end) @@ -212,6 +220,7 @@ function ItemDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -236,7 +245,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 ad6b6a48f39..c8fe4fb5a2a 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -594,7 +594,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 = { } @@ -609,7 +609,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 = { @@ -677,6 +677,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", { "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 7564354cb4a..09fa9dedca3 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -5,6 +5,7 @@ -- local dkjson = require "dkjson" +local WeightedScore = LoadModule("Modules/WeightedScore") local curl = require("lcurl.safe") local m_max = math.max local s_format = string.format @@ -162,40 +163,7 @@ local TradeQueryGeneratorClass = newClass("TradeQueryGenerator", function(self, 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) @@ -1462,4 +1430,4 @@ Remove: %s will be removed from the search results.]], term, term, term) setModSelectors(controls, getModList()) main:OpenPopup(popupWidth, popupHeight, "Query Options", controls) -end \ No newline at end of file +end diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 254ca5ffe57..4b0d9742856 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" @@ -270,6 +271,19 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, 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", + { "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", { "TOPLEFT", self.controls.specSelect, "BOTTOMLEFT" }, { 0, yPos, 700, 170 }, function(selectedNode) @@ -458,6 +472,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) @@ -1040,6 +1055,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 ed3ab9af5eb..28b54b08204 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -169,6 +169,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 fd21b85b4c6a2e72dc0550b1406d12d3985554ec Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:32 +0100 Subject: [PATCH 02/21] 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 aef0bc99510fa98129fdee3013197be875fef61d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 22 Mar 2026 19:00:45 +0100 Subject: [PATCH 03/21] 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 e3fb85b5bb4..1220903b411 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 @@ -95,6 +96,7 @@ function NotableDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -131,12 +133,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 fc1522dcc07f28ebb1ff6c20c0d7e69b6e63aa16 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 00:00:00 +0100 Subject: [PATCH 04/21] 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 28b54b08204..634d2f2f115 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -169,7 +169,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 c4ad3cb2850fc74fa02491df773ec08864f4964f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 23:11:33 +0100 Subject: [PATCH 05/21] 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 634d2f2f115..58e7b5bb261 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 @@ -170,7 +171,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 f1fecb14ca694396287c9b20cac064712a197375 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:18:29 +0100 Subject: [PATCH 06/21] 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 4b0d9742856..3779c874a42 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1844,7 +1844,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 @@ -1852,7 +1858,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 370685997a4ead3dec071c292868ed957fb3d915 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:38:23 +0100 Subject: [PATCH 07/21] 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 3779c874a42..0d3f3038780 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1994,6 +1994,7 @@ function TreeTabClass:FindTimelessJewel() label = "Sort by " .. stat.label, stat = stat.stat, transform = stat.transform, + getValue = stat.getValue, }) end end From 64f93f96a36fee034f4a1a83d61473ae1a07f904 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 28 Mar 2026 11:33:00 +0100 Subject: [PATCH 08/21] 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 c8fe4fb5a2a..71e9cedd687 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -596,6 +596,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 e454a3398081d784e93f3443ef85eb1fe6dbcc54 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 9 May 2026 09:45:19 +0200 Subject: [PATCH 09/21] 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 20994b751bd..237b9c02237 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -489,7 +489,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 bd2c1d2cd3c..85db2ee7d55 100644 --- a/src/Classes/PowerReportListControl.lua +++ b/src/Classes/PowerReportListControl.lua @@ -108,6 +108,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 58e7b5bb261..1d0477633b1 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -170,9 +170,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 81a29881103e1d1dfb0017c749a5aaf175bf4e54 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 10 May 2026 08:23:01 +0200 Subject: [PATCH 10/21] 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 | 29 +++++++++++++++++++++++++ src/Classes/ItemDBControl.lua | 21 ++++++++++-------- src/Classes/NotableDBControl.lua | 13 ++++++++++- src/Classes/TreeTab.lua | 30 ++++++++++++-------------- src/Modules/WeightedScore.lua | 19 ++++++++++++++++ 5 files changed, 86 insertions(+), 26 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 58ef0d621e1..6f4d6a472f4 100644 --- a/spec/System/TestWeightedScore_spec.lua +++ b/spec/System/TestWeightedScore_spec.lua @@ -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 ac9c2bc3628..b5bc277b481 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -36,19 +36,16 @@ local ItemDBClass = newClass("ItemDBControl", "ListControl", function(self, anch end) if dbType == "UNIQUE" then self.controls.sort = new("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", {"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, self.leagueList, function(index, value) self.listBuildFlag = true end) - self.controls.editWeights = new("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", {"LEFT",self.controls.sort,"BOTTOMLEFT"}, {0, 11, 179, 18}, { "Any requirements", "Current level", "Current attributes", "Current useable" }, function(index, value) self.listBuildFlag = true end) @@ -224,6 +221,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 1220903b411..937dadd4098 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -34,7 +34,12 @@ local NotableDBClass = newClass("NotableDBControl", "ListControl", function(self self.sortOrder = { } self.sortMode = "NAME" self.controls.sort = new("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", {"BOTTOMLEFT",self,"TOPLEFT"}, {0, -2, 258, 18}, "", "Search", "%c", 100, function() self.listBuildFlag = true @@ -100,6 +105,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 diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 0d3f3038780..8aeccebabee 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -251,7 +251,14 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) -- Control for selecting the power stat to sort by (Defense, DPS, etc) self.controls.treeHeatMapStatSelect = new("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+)") @@ -264,6 +271,12 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, 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", { "LEFT", self.controls.treeHeatMapStatSelect, "RIGHT" }, { 8, 0, 150, 20 }, @@ -271,19 +284,6 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, 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", - { "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", { "TOPLEFT", self.controls.specSelect, "BOTTOMLEFT" }, { 0, yPos, 700, 170 }, function(selectedNode) @@ -472,7 +472,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) @@ -1055,7 +1054,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 51551a31fc62c1979b061d26aebbe0e825fbc2bf Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 20:54:14 +0200 Subject: [PATCH 11/21] 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 6f4d6a472f4..ea063d19c90 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 f46ef8833fdd979e8abeeb3170cc5d0c946a04ef Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 20:57:47 +0200 Subject: [PATCH 12/21] 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 ea063d19c90..e64bc671dc7 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 7171ea2db24..b76db6eab64 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -69,7 +69,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 1d0477633b1..65d5fa30fd6 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -225,7 +225,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 7f54fe4e41a5b82d660eb33be8e7978db21e2ce5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:02:18 +0200 Subject: [PATCH 13/21] 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 85dc8efa4b8..5f4c05af89f 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -52,6 +52,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", [[ Rarity: Unique diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index b5bc277b481..54940fc6d42 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -252,11 +252,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 884e1c262349446b7c841cb07952c0c9501e1b35 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:10:34 +0200 Subject: [PATCH 14/21] 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 e64bc671dc7..9e35d032f02 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 33f41615acd..ef5d1e050d5 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 @@ -2475,10 +2476,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 @@ -2581,7 +2587,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) @@ -2878,7 +2885,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 @@ -2961,7 +2969,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 @@ -3030,7 +3039,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 8aeccebabee..aebcb11432f 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1841,10 +1841,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 @@ -1855,7 +1860,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 bb48441f44eaed5ad2e3d0bcefaa2c2d03550775 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:13:44 +0200 Subject: [PATCH 15/21] 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 9e35d032f02..b0d4a767c73 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 b3709c210e99a86de0f2f1c310490fae301b848c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:16:50 +0200 Subject: [PATCH 16/21] 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 71e9cedd687..c189ca5ad51 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 @@ -180,23 +181,24 @@ function TradeQueryClass:PriceBuilderProcessPoENinjaResponse(responseLines) 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 0137fd00e0842bb74275befeadecdb79725cdf1c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:24:08 +0200 Subject: [PATCH 17/21] fix(weighted-score): use Full DPS for anoint ranking --- spec/System/TestNotableDBControl_spec.lua | 37 +++++++++++++++++++++++ src/Classes/NotableDBControl.lua | 8 +++-- 2 files changed, 42 insertions(+), 3 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 937dadd4098..a99f90ddfce 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -143,13 +143,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 @@ -305,4 +307,4 @@ end ---@param node table function NotableDBClass:OnSelCopy(index, node) Copy(item.dn) -end \ No newline at end of file +end From 5cc4da43559764f08ed0738a38dbeeb07487e9c8 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 26 Jul 2026 21:26:12 +0200 Subject: [PATCH 18/21] 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 68eaf5d32c7..367f9b916c3 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 c189ca5ad51..d57eba573d6 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -679,6 +679,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 7ed1bc6bdfca32c5e1f77f9022fc0f4d94941cf5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 09:17:11 +0200 Subject: [PATCH 19/21] 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 b0d4a767c73..165468f7723 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 65d5fa30fd6..61302d25fab 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -170,15 +170,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 @@ -234,6 +225,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 7f428e847f8efb6cfb66df649882bc06ca53ffb3 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 09:30:47 +0200 Subject: [PATCH 20/21] 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 165468f7723..6b35c6faf00 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 ddeca8208adeb7fe7c614a6778a3303929b7db58 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Jul 2026 11:18:41 +0200 Subject: [PATCH 21/21] 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 | 136 +++++++++++++++++-------- src/Modules/Data.lua | 27 ++++- src/Modules/WeightedScore.lua | 19 ++++ 4 files changed, 199 insertions(+), 43 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 6b35c6faf00..195dba81088 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 b76db6eab64..6427abeb5ac 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 = { @@ -69,14 +70,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 + local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Control", function(self, build) self.UndoHandler() self.ControlHost() @@ -2486,7 +2495,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] @@ -2508,7 +2517,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 @@ -2518,11 +2527,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 @@ -2547,6 +2555,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", {"TOPRIGHT",nil,"TOPLEFT"}, {95, 20, 0, 16}, "^7Skill:") controls.skill = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {100, 20, 180, 18}, skillList, function(index, value) @@ -2581,9 +2595,12 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end end) controls.sortLabel = new("LabelControl", {"TOPRIGHT",nil,"TOPLEFT"}, {350, 45, 0, 16}, "^7Sort by:") - controls.sort = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {355, 45, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("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", {"TOPRIGHT",nil,"TOPLEFT"}, {95, 70, 0, 16}, "^7Enchantment:") controls.enchantment = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {100, 70, 495, 18}, enchantmentList) controls.enchantment.tooltipFunc = function(tooltip, mode, index) @@ -2796,7 +2813,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] @@ -2817,17 +2834,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 @@ -2845,14 +2862,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) @@ -2868,6 +2888,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", self.displayItem:BuildRaw()) item.id = self.displayItem.id @@ -3031,9 +3059,12 @@ function ItemsTabClass:CorruptDisplayItem(modType) end) controls.source.enabled = #sourceList > 1 controls.sortLabel = new("LabelControl", {"TOPRIGHT",nil,"TOPLEFT"}, {350, 20, 0, 16}, "^7Sort by:") - controls.sort = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat) - end) + controls.sort = new("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", { "TOPLEFT", nil, "TOPLEFT" }, { 20, implicitYPos + implicitRowSize, 0, 20 }, "^7This Items Implicits Cannot Be Changed") @@ -3098,7 +3129,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] @@ -3110,7 +3141,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 @@ -3120,11 +3151,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 @@ -3149,6 +3179,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) @@ -3351,9 +3387,12 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() controls.sortLabel.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end - controls.sort = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("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 @@ -3685,7 +3724,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] @@ -3695,7 +3734,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 @@ -3706,12 +3745,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 @@ -3769,6 +3807,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", self.displayItem:BuildRaw()) item.id = self.displayItem.id @@ -3805,9 +3854,12 @@ function ItemsTabClass:AddImplicitToDisplayItem() controls.sortLabel.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end - controls.sort = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("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 61302d25fab..9f2f24b19a6 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -123,7 +123,7 @@ LoadModule("Data/Misc", data) 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" }, @@ -204,6 +204,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, @@ -229,6 +251,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