From 8d7e9c75c66708b72c2faa2dbd6d0ce628cc29b2 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:24 +0100 Subject: [PATCH 01/10] 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 | 28 +------------- src/Classes/TreeTab.lua | 16 ++++++++ src/Modules/Data.lua | 1 + src/Modules/WeightedScore.lua | 60 +++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 src/Modules/WeightedScore.lua diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index ce9303856d..aac56ccc81 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" }, @@ -614,6 +615,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 if modified.Minion and selection.stat ~= "FullDPS" then original = original.Minion modified = modified.Minion diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 3136d1a847..3813e9ca6c 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) @@ -198,6 +206,7 @@ function ItemDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) end end @@ -222,7 +231,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 9e1308bfb9..f6edc0ea42 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -548,7 +548,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 = { } @@ -558,7 +558,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) controls.ListControl = new("TradeStatWeightMultiplierListControl", {"TOPLEFT", nil, "TOPRIGHT"}, {-410, 45, 400, 200}, statList, sliderController) for id, stat in pairs(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 = { @@ -626,6 +626,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 eeb2fdeaab..46b4e9be73 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 @@ -172,32 +173,7 @@ local function canModSpawnForItemCategory(mod, category) 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 + (baseOutput[mod] or 0) - newModSum = newModSum + (newOutput[mod] or 0) - 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 - if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then - meanStatDiff = meanStatDiff + ratioModSums("TotalDPS", "TotalDotDPS", "CombinedDPS") * statTable.weightMult - end - meanStatDiff = meanStatDiff + ratioModSums(statTable.stat) * statTable.weightMult - end - return meanStatDiff + return WeightedScore.computeRatioScore(baseOutput, newOutput, statWeights) end function TradeQueryGeneratorClass:ProcessMod(modId, mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 8e16490f57..bc20d656c0 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 4923986364..5bf7bf950e 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -158,6 +158,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 }, } data.misc = { -- magic numbers diff --git a/src/Modules/WeightedScore.lua b/src/Modules/WeightedScore.lua new file mode 100644 index 0000000000..831c1ba943 --- /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 0de0e1f1996bd41f8fd0fb72a69b5fb9572a4a0e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 21 Mar 2026 12:21:32 +0100 Subject: [PATCH 02/10] 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 0000000000..3d67596c85 --- /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 19da741801ac52a93d00a69b573c872fc4fcceb9 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 22 Mar 2026 19:00:45 +0100 Subject: [PATCH 03/10] 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 d08ec4080c..f04add1a75 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 @@ -139,12 +141,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 7fd8bd2e6416ae6624a7dde0371890489c1a6c27 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 00:00:00 +0100 Subject: [PATCH 04/10] 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 5bf7bf950e..43f2b656d7 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -158,7 +158,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 }, } data.misc = { -- magic numbers From 4e81b212928d9b3a83a4950313b975da85d63203 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 23 Mar 2026 23:11:33 +0100 Subject: [PATCH 05/10] 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 43f2b656d7..bebb6f4977 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -6,6 +6,7 @@ LoadModule("Data/Global") +local WeightedScore = LoadModule("Modules/WeightedScore") local m_min = math.min local m_max = math.max local m_floor = math.floor @@ -159,7 +160,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 cebff5fc52af644f442f039e16f0a47d5bbd1dee Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:18:29 +0100 Subject: [PATCH 06/10] 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 | 49 ++++++++++++++------------ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua index 3d67596c85..da24c2b20d 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 bc20d656c0..4646d79e8d 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1851,29 +1851,37 @@ function TreeTabClass:FindTimelessJewel() local function generateFallbackWeights(nodes, selection) local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) local newList = { } - local baseOutput = calcFunc() + local baseRawOutput = calcFunc() + local baseOutput = baseRawOutput if baseOutput.Minion then baseOutput = baseOutput.Minion end - local baseValue = baseOutput[selection.stat] or 1 - if selection.transform then - baseValue = selection.transform(baseValue) + local function getStatValue(scopedOutput, rawOutput) + if selection.getValue then + return selection.getValue(rawOutput, self.build) + end + local value = scopedOutput[selection.stat] or 0 + if selection.transform then + value = selection.transform(value) + end + return value + end + local baseValue = getStatValue(baseOutput, baseRawOutput) + if baseValue == 0 then + baseValue = 1 end for _, newNode in ipairs(nodes) do - local output = nil + local rawOutput = nil if newNode.calcMultiple then - output = calcFunc({ addNodes = { [newNode.node[1]] = true } }) + rawOutput = calcFunc({ addNodes = { [newNode.node[1]] = true } }) else - output = calcFunc({ addNodes = { [newNode] = true } }) + rawOutput = calcFunc({ addNodes = { [newNode] = true } }) end - if output.Minion then - output = output.Minion + local scopedOutput = rawOutput + if scopedOutput.Minion then + scopedOutput = scopedOutput.Minion end - local outputValue = output[selection.stat] or 0 - if selection.transform then - outputValue = selection.transform(outputValue) - end - outputValue = outputValue / baseValue + local outputValue = getStatValue(scopedOutput, rawOutput) / baseValue if outputValue ~= outputValue then outputValue = 1 end @@ -1882,15 +1890,12 @@ function TreeTabClass:FindTimelessJewel() weight1 = (outputValue - 1) / (newNode.divisor or 1) }) if newNode.calcMultiple then - output = calcFunc({ addNodes = { [newNode.node[2]] = true } }) - if output.Minion then - output = output.Minion - end - outputValue = output[selection.stat] or 0 - if selection.transform then - outputValue = selection.transform(outputValue) + rawOutput = calcFunc({ addNodes = { [newNode.node[2]] = true } }) + scopedOutput = rawOutput + if scopedOutput.Minion then + scopedOutput = scopedOutput.Minion end - outputValue = outputValue / baseValue + outputValue = getStatValue(scopedOutput, rawOutput) / baseValue if outputValue ~= outputValue then outputValue = 1 end From 10029679bd4c579920522c6c89115b4b2f57040e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 24 Mar 2026 09:38:23 +0100 Subject: [PATCH 07/10] 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 da24c2b20d..8fd2c1c59e 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 4646d79e8d..4b357dc68e 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -2025,6 +2025,7 @@ function TreeTabClass:FindTimelessJewel() label = "Sort by " .. stat.label, stat = stat.stat, transform = stat.transform, + getValue = stat.getValue, }) end end From a425a7820e0a643cbbe7681395a0c7bd27d5899f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 28 Mar 2026 11:33:00 +0100 Subject: [PATCH 08/10] 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 f6edc0ea42..d3a8ea4317 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -550,6 +550,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 22824613639a0b81255a35eb40216490b30eaa06 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 9 May 2026 09:45:19 +0200 Subject: [PATCH 09/10] 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 0000000000..1a7d722088 --- /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 8fd2c1c59e..58ef0d621e 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 aac56ccc81..a2ef605190 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -475,7 +475,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 0b08bdd60f..afef1a7317 100644 --- a/src/Classes/PowerReportListControl.lua +++ b/src/Classes/PowerReportListControl.lua @@ -102,6 +102,8 @@ function PowerReportListClass:ReList() end if self.allocated then insert = item.allocated + elseif item.allocated then + insert = false end if insert then diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index bebb6f4977..bf17d107c3 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -159,9 +159,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 831c1ba943..752c67fc0d 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 359f90041bf209cce317735b11389392ba15d9bf Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 10 May 2026 08:23:01 +0200 Subject: [PATCH 10/10] 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 58ef0d621e..6f4d6a472f 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 3813e9ca6c..b2ae92ac00 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) @@ -210,6 +207,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 f04add1a75..c3c137d99e 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 4b357dc68e..2ad6092411 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 752c67fc0d..0eb0cdc403 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