diff --git a/spec/System/TestAbyssTimelessJewel_spec.lua b/spec/System/TestAbyssTimelessJewel_spec.lua index 8ceca5cade..741bfaa7dd 100644 --- a/spec/System/TestAbyssTimelessJewel_spec.lua +++ b/spec/System/TestAbyssTimelessJewel_spec.lua @@ -300,6 +300,42 @@ describe("Abyss timeless jewels", function() assert.matches("abyss_special_small_attribute25, 1, 0, 0", build.timelessData.searchListFallback, nil, true) end) + it("exposes weight editing beside the Weighted Score fallback mode", function() + build.timelessData.jewelType = { id = 11 } + build.timelessData.conquerorType = { } + build.timelessData.jewelSocket = { id = 26196 } + build.itemsTab.tradeQuery.statSortSelectionList = { + { stat = "FullDPS", label = "Full DPS", weightMult = 1 }, + } + build.treeTab:FindTimelessJewel() + local control = main.popups[1].controls.fallbackWeightsList + local weightedIndex + local weightedCount = 0 + for index, entry in ipairs(control.list) do + if entry.isWeightedScore then + weightedIndex = index + weightedCount = weightedCount + 1 + end + end + assert.are.equal(1, weightedCount) + assert.is_truthy(weightedIndex) + assert.is_true(control.list[weightedIndex + 1].isAction) + assert.is_true(data.powerStatList.RequiresFullDPS(control.list[weightedIndex], build)) + + local opened = false + build.itemsTab.tradeQuery.SetStatWeights = function() + opened = true + end + control:SetSel(weightedIndex) + for char in ("Edit"):gmatch(".") do + control:OnSearchChar(char) + end + control:SetSel(1) + + assert.is_true(opened) + assert.are.equal("WeightedScore", control:GetSelValue().stat) + end) + it("reads Zorath seed 6564 node and Inquisitor ascendancy changes", function() data.timelessJewelLUTs[11] = parseAbyssJewel(11, zorathExampleData()) local expected = { diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua index 3a77a067e9..6ace8b3e30 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -62,6 +62,103 @@ describe("ItemDBControl", function() assert.are.equal(-math.huge, invalidItem.measuredPower) end) + it("preserves negative WeightedScore results and skips unneeded FullDPS", function() + local function makeItem(name) + return { + name = name, + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + end + local betterItem = makeItem("Better Item") + local worseItem = makeItem("Worse Item") + local invalidItem = makeItem("Invalid Item") + local takenDamage = { + [betterItem] = 80, + [worseItem] = 120, + } + local requestedFullDPS = { } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + tradeQuery = { + statSortSelectionList = { + { stat = "PhysicalTakenHit", weightMult = 1, transform = function(value) return -value end }, + }, + }, + IsItemValidForSlot = function(_, item) + return item ~= invalidItem + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(args, useFullDPS) + table.insert(requestedFullDPS, useFullDPS) + return { PhysicalTakenHit = takenDamage[args.repItem] } + end, { PhysicalTakenHit = 100 } + end, + }, + } + local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { + list = { invalidItem, betterItem, worseItem }, + }, "RARE") + control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.equal(betterItem, control.list[1]) + assert.are.equal(worseItem, control.list[2]) + assert.are.equal(invalidItem, control.list[3]) + assert.are.equal(-0.8, betterItem.measuredPower) + assert.are.equal(-1.2, worseItem.measuredPower) + assert.are.equal(-math.huge, invalidItem.measuredPower) + assert.are.same({ false, false }, requestedFullDPS) + end) + + it("requests FullDPS for WeightedScore when active weights need it", function() + local item = { + name = "Full DPS Item", + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + local requestedFullDPS = { } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + tradeQuery = { statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } }, + IsItemValidForSlot = function() + return true + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(_, useFullDPS) + table.insert(requestedFullDPS, useFullDPS) + return { FullDPS = 120 } + end, { FullDPS = 100 } + end, + }, + } + local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") + control.sortDetail = { stat = "WeightedScore", isWeightedScore = true } + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.same({ true }, requestedFullDPS) + end) + it("searches Foulborn modifier text without case sensitivity", function() local item = new("Item", [[ Rarity: Unique diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua new file mode 100644 index 0000000000..7a5c31222f --- /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/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/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 68eaf5d32c..b87ac1c875 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -114,4 +114,60 @@ 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) + + it("preserves the save callback after resetting weights", function() + local capturedControls + local originalOpenPopup = main.OpenPopup + local originalClosePopup = main.ClosePopup + main.OpenPopup = function(_, _, _, _, controls) + capturedControls = controls + end + main.ClosePopup = function() end + + local callbackCount = 0 + local ok, errMsg = pcall(function() + local tradeQuery = new("TradeQuery", {}) + tradeQuery:SetStatWeights(nil, function() + callbackCount = callbackCount + 1 + end) + capturedControls.reset.onClick() + capturedControls.finalise.onClick() + end) + main.OpenPopup = originalOpenPopup + main.ClosePopup = originalClosePopup + + assert.is_true(ok, errMsg) + assert.are.equal(1, callbackCount) + end) + end) end) diff --git a/spec/System/TestWeightedScore_spec.lua b/spec/System/TestWeightedScore_spec.lua new file mode 100644 index 0000000000..316a50acae --- /dev/null +++ b/spec/System/TestWeightedScore_spec.lua @@ -0,0 +1,617 @@ +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("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 } + 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) + + -- 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() + 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) + assert.are.equal("WeightedScore", data.powerStatList[#data.powerStatList].stat) + end) + + it("does not create a Minion WeightedScore entry", function() + assert.is_nil(findStat("MinionWeightedScore")) + end) + + -- 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) + + 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 + it("getValue on WeightedScore entry returns positive score for better output", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + assert.is_function(stat.getValue) + local baseOutput = { FullDPS = 100, TotalEHP = 100 } + local betterOutput = { FullDPS = 201, TotalEHP = 100 } + local baseScore = stat.getValue(baseOutput, build, baseOutput) + local betterScore = stat.getValue(betterOutput, build, baseOutput) + assert.is_true(betterScore > baseScore) + end) + + it("power stat helpers 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) + 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 + -- 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) + + -- 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 after WeightedScore", 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) + assert.is_true(list[2].isWeightedScore) + 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) + +describe("WeightedScore — selector contracts", function() + local function findWeightedScore(list) + local weightedIndex + local weightedCount = 0 + for index, entry in ipairs(list) do + if entry.isWeightedScore then + weightedIndex = index + weightedCount = weightedCount + 1 + end + end + assert.are.equal(1, weightedCount) + assert.is_truthy(weightedIndex) + assert.is_truthy(list[weightedIndex + 1]) + assert.is_true(list[weightedIndex + 1].isAction) + return weightedIndex + end + + before_each(function() + newBuild() + end) + + it("opens weight editing from crafted modifier sorting and restores Weighted Score", function() + local itemsTab = build.itemsTab + itemsTab:CreateDisplayItemFromRaw([[ +Rarity: RARE +Weighted Selector Helmet +Royal Burgonet +Item Level: 86 +Crafted: true +Prefix: None +Prefix: None +Prefix: None +Suffix: None +Suffix: None +Suffix: None +Quality: 20 +Implicits: 0 +]]) + local control = itemsTab.controls.craftingSorting + local weightedIndex = findWeightedScore(control.list) + local opened = false + itemsTab.tradeQuery.SetStatWeights = function(_, _, onSave) + opened = true + onSave() + end + + control:SetSel(weightedIndex) + control:SetSel(weightedIndex + 1) + + assert.is_true(opened) + assert.are.equal("WeightedScore", control:GetSelValue().stat) + end) + + it("opens weight editing from Compare Power and invalidates the selected report", function() + local compareTab = build.compareTab + local control = compareTab.controls.comparePowerStatSelect + local weightedIndex = findWeightedScore(control.list) + local opened = false + build.itemsTab.tradeQuery.SetStatWeights = function(_, _, onSave) + opened = true + onSave() + end + + control:SetSel(weightedIndex) + compareTab.comparePowerDirty = false + control:SetSel(weightedIndex + 1) + + assert.is_true(opened) + assert.are.equal("WeightedScore", control:GetSelValue().stat) + assert.is_true(compareTab.comparePowerDirty) + end) +end) + +describe("WeightedScore — crafted affix sorting", function() + local originalGetMiscCalculator + local originalGetValue + + before_each(function() + newBuild() + originalGetMiscCalculator = build.calcsTab.GetMiscCalculator + originalGetValue = data.powerStatList.GetValue + end) + + after_each(function() + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + data.powerStatList.GetValue = originalGetValue + end) + + it("evaluates contextual scores with their baseline and Full DPS requirement", function() + local itemsTab = build.itemsTab + itemsTab:CreateDisplayItemFromRaw([[ +Rarity: RARE +Weighted Sort Helmet +Royal Burgonet +Item Level: 86 +Crafted: true +Prefix: None +Prefix: None +Prefix: None +Suffix: None +Suffix: None +Suffix: None +Quality: 20 +Implicits: 0 +]]) + itemsTab.tradeQuery.statSortSelectionList = { + { stat = "FullDPS", label = "Full DPS", weightMult = 1 }, + } + itemsTab.controls.craftingSorting:SelByValue("WeightedScore", "stat") + + local useFullDPSCalls = { } + build.calcsTab.GetMiscCalculator = function() + return function(params, useFullDPS) + useFullDPSCalls[#useFullDPSCalls + 1] = useFullDPS + return { + modCount = params and params.repItem and #params.repItem.explicitModLines or 0, + } + end + end + + local getValueCalls = 0 + local sawBaseline = false + data.powerStatList.GetValue = function(output, statTable, ownerBuild, calcBase) + getValueCalls = getValueCalls + 1 + sawBaseline = sawBaseline or (calcBase and calcBase.modCount == 0) + assert.are.equal("WeightedScore", statTable.stat) + assert.are.equal(build, ownerBuild) + return output.modCount + end + + local control = itemsTab.controls.displayItemAffix1 + itemsTab:UpdateAffixControl( + control, + itemsTab.displayItem, + "Prefix", + "prefixes", + 1, + { } + ) + + assert.is_true(getValueCalls > 0, "crafted affix sorting must evaluate contextual stats through GetValue") + assert.is_true(sawBaseline, "contextual scoring must receive the item without the candidate affix") + assert.is_true(#useFullDPSCalls > 0) + for _, useFullDPS in ipairs(useFullDPSCalls) do + assert.is_true(useFullDPS) + end + assert.is_not_nil(control.list[2].modList) + local highestScoredMod = itemsTab.displayItem.affixes[control.list[2].modList[1]] + assert.are.equal(2, #highestScoredMod, "the highest contextual score must sort first") + end) +end) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index f621c7de75..237b9c0223 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" }, @@ -488,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 = { } @@ -733,6 +738,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/CompareTab.lua b/src/Classes/CompareTab.lua index c490fb9a12..6eb6438ce5 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 @@ -972,8 +973,21 @@ function CompareTabClass:InitControls() t_insert(powerStatList, entry) end end + WeightedScore.appendEditWeightsAction(powerStatList, function() + self.controls.comparePowerStatSelect:SelByValue(self.comparePowerStat and self.comparePowerStat.stat, "stat") + local tradeQuery = self.primaryBuild.itemsTab.tradeQuery + if tradeQuery then + tradeQuery:SetStatWeights(nil, function() + if self.comparePowerStat and self.comparePowerStat.isWeightedScore then + self.comparePowerDirty = true + end + end) + end + end) self.controls.comparePowerStatSelect = new("DropDownControl", nil, {0, 0, 200, 20}, powerStatList, function(index, value) - if value and value.stat and value ~= self.comparePowerStat then + if value and value.isAction then + value.action() + elseif value and value.stat and value ~= self.comparePowerStat then self.comparePowerStat = value self.comparePowerDirty = true elseif value and not value.stat then @@ -2486,10 +2500,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 @@ -2592,7 +2611,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) @@ -2889,7 +2909,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 @@ -2972,7 +2993,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 @@ -3041,7 +3063,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/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 84e8e2f5f6..47946518da 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) @@ -36,7 +36,12 @@ 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 @@ -212,9 +217,16 @@ function ItemDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) 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) @@ -236,7 +248,28 @@ 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) + local useFullDPS = WeightedScore.weightsNeedFullDPS(weights) + for itemIndex, item in ipairs(list) do + item.measuredPower = -math.huge + for slotName, slot in pairs(self.itemsTab.slots) do + if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then + local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }, useFullDPS) + local score = WeightedScore.computeRatioScore(calcBase, output, weights) + item.measuredPower = m_max(item.measuredPower, score) + end + end + local now = GetTime() + if now - start > 50 then + self.defaultText = "^7Sorting... ("..m_floor(itemIndex/#list*100).."%)" + coroutine.yield() + start = now + end + end + elseif self.sortDetail and self.sortDetail.stat then -- stat-based local useFullDPS = self.sortDetail.stat == "FullDPS" local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 1380881c02..58fcd7f9a2 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 = { @@ -71,13 +72,21 @@ local function buildModSortList() local sortStats = { } for _, entry in ipairs(data.powerStatList) do if entry.stat and not entry.ignoreForNodes then - t_insert(sortList, { label = entry.label, stat = entry.stat }) + 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() @@ -651,6 +660,15 @@ holding Shift will put it in the second.]]) table.insert(sortingOptions, option) end end + local activeCraftingSort = sortingOptions[1] + WeightedScore.appendEditWeightsAction(sortingOptions, function() + self.controls.craftingSorting:SelByValue(activeCraftingSort.stat, "stat") + if self.tradeQuery then + self.tradeQuery:SetStatWeights(nil, function() + self:UpdateAffixControls() + end) + end + end) -- Section: Catalysts self.controls.displayItemSectionCatalyst = new("Control", {"TOPLEFT",self.controls.displayItemSectionQuality,"BOTTOMLEFT"}, {0, 0, 0, function() return (self.controls.displayItemCatalyst:IsShown() or self.controls.displayItemCatalystQualityEdit:IsShown()) and 28 or 0 @@ -720,8 +738,13 @@ holding Shift will put it in the second.]]) -- cluster jewels don't have good comparison support and sorting would be misleading not (self.displayItem.base.type == "Jewel" and self.displayItem.base.subType == "Cluster") end - self.controls.craftingSorting = new("DropDownControl", { "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function() - self:UpdateAffixControls() + self.controls.craftingSorting = new("DropDownControl", { "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function(index, value) + if value.isAction then + value.action() + else + activeCraftingSort = value + self:UpdateAffixControls() + end end) -- Section: Affix Selection @@ -2221,6 +2244,7 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, if sortOption.stat and self.controls.craftingSortingLabel.shown() then local calcFunc = self.build.calcsTab:GetMiscCalculator() local slotName = self.displayItem:GetPrimarySlot() + local useFullDPS = data.powerStatList.RequiresFullDPS(sortOption, self.build) local testSubject = new("Item", self.displayItem:BuildRaw()) local controlPowerCache = powerCache if selAffix and selAffix ~= "None" then @@ -2228,6 +2252,8 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, testSubject:Craft() controlPowerCache = { } end + local calcBase = sortOption.getValue + and calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS) local function pickModifierFromList(modList) -- pick mid tier modifier from a group if #modList == 1 then @@ -2256,9 +2282,11 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, t_insert(testSubject.explicitModLines, modLine) end testSubject:BuildAndParseRaw() - power = data.powerStatList.GetFromOutput( - calcFunc({ repSlotName = slotName, repItem = testSubject }), - sortOption + power = data.powerStatList.GetValue( + calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS), + sortOption, + self.build, + calcBase ) testSubject = new("Item", originalItem) else @@ -2270,9 +2298,11 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, end testSubject:BuildModList() - power = data.powerStatList.GetFromOutput( - calcFunc({ repSlotName = slotName, repItem = testSubject }), - sortOption + power = data.powerStatList.GetValue( + calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS), + sortOption, + self.build, + calcBase ) for _ = 1, modCount do t_remove(testSubject.explicitModLines, #testSubject.explicitModLines) @@ -2751,7 +2781,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] @@ -2773,7 +2803,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 @@ -2783,11 +2813,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 @@ -2812,6 +2841,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) @@ -2846,9 +2881,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) @@ -3061,7 +3099,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] @@ -3082,17 +3120,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 @@ -3110,14 +3148,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) @@ -3133,6 +3174,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 @@ -3286,9 +3335,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") @@ -3353,7 +3405,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] @@ -3365,7 +3417,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 @@ -3375,11 +3427,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 @@ -3404,6 +3455,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) @@ -3620,9 +3677,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 @@ -3954,7 +4014,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] @@ -3964,7 +4024,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 @@ -3975,12 +4035,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 @@ -4038,6 +4097,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 @@ -4074,9 +4144,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/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index e3fb85b5bb..a99f90ddfc 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 @@ -33,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 @@ -95,9 +101,16 @@ function NotableDBClass:BuildSortOrder() itemField=stat.itemField, stat=stat.stat, transform=stat.transform, + isWeightedScore=stat.isWeightedScore, }) 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 @@ -130,13 +143,20 @@ 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) }) - node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) + local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }, useFullDPS) + if self.sortDetail.isWeightedScore then + node.measuredPower = WeightedScore.computeRatioScore(calcBase, output, weights) + else + node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) + end if node.measuredPower == m_huge then t_insert(infinites, node) else @@ -287,4 +307,4 @@ end ---@param node table function NotableDBClass:OnSelCopy(index, node) Copy(item.dn) -end \ No newline at end of file +end diff --git a/src/Classes/PowerReportListControl.lua b/src/Classes/PowerReportListControl.lua index bd2c1d2cd3..85db2ee7d5 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/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index aa7c29a463..732282eb41 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 @@ -250,23 +251,24 @@ function TradeQueryClass:PullCXData() end local function initStatSortSelectionList(list) - t_insert(list, { - label = "Full DPS", - stat = "FullDPS", - weightMult = 1.0, - }) - t_insert(list, { - label = "Effective Hit Pool", - stat = "TotalEHP", - weightMult = 0.5, - }) + for _, weight in ipairs(WeightedScore.defaultWeights()) do + t_insert(list, weight) + end end -- we do not want to overwrite previous list if the new list is the default, e.g. hitting reset multiple times in a row local function isSameAsDefaultList(list) - return list and #list == 2 - and list[1].stat == "FullDPS" and list[1].weightMult == 1.0 - and list[2].stat == "TotalEHP" and list[2].weightMult == 0.5 + local defaultWeights = WeightedScore.defaultWeights() + if not list or #list ~= #defaultWeights then + return false + end + for index, weight in ipairs(defaultWeights) do + local selectedWeight = list[index] + if selectedWeight.stat ~= weight.stat or selectedWeight.weightMult ~= weight.weightMult then + return false + end + end + return true end -- Opens the item pricing popup @@ -662,8 +664,12 @@ 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 {} + if not self.statSortSelectionList or (#self.statSortSelectionList) == 0 then + self.statSortSelectionList = { } + initStatSortSelectionList(self.statSortSelectionList) + end local controls = { } local statList = { } local sliderController = { index = 1 } @@ -677,7 +683,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 = { @@ -741,10 +747,12 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) 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) 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 @@ -762,7 +770,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = { } initStatSortSelectionList(self.statSortSelectionList) main:ClosePopup() - self:SetStatWeights(previousSelection) + self:SetStatWeights(previousSelection, onSave) end) main:OpenPopup(420, popupHeight, "Stat Weight Multipliers", controls) end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index f4a824c1f6..4b973cef15 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 @@ -164,40 +165,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) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 9db3f378b4..7e0cf1d242 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" @@ -250,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+)") @@ -263,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 }, @@ -1928,16 +1942,26 @@ function TreeTabClass:FindTimelessJewel() local function generateFallbackWeights(nodes, powerStat) local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) + local useFullDPS = data.powerStatList.RequiresFullDPS(powerStat, self.build) + if useFullDPS then + calcBase = calcFunc(nil, true) + end local newList = { } - local basePower = data.powerStatList.GetFromOutput(calcBase, powerStat) + local function getStatValue(output) + if powerStat.getValue then + return powerStat.getValue(output, self.build, calcBase) + 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 local nodeLines = newNode.node or { newNode } for i = 1, #nodeLines do local node = nodeLines[i] - local nodeOutput = calcFunc({ addNodes = { [node] = true } }) - local nodePower = data.powerStatList.GetFromOutput(nodeOutput, powerStat) + local nodeOutput = calcFunc({ addNodes = { [node] = true } }, useFullDPS) + local nodePower = getStatValue(nodeOutput) -- avoid infinity if basePower == 0 then powerEntry["weight" .. i] = 0 @@ -2101,14 +2125,35 @@ function TreeTabClass:FindTimelessJewel() label = "Sort by " .. stat.label, stat = stat.stat, transform = stat.transform, + getValue = stat.getValue, + requiresFullDPS = stat.requiresFullDPS, + isWeightedScore = stat.isWeightedScore, }) end end - controls.fallbackWeightsList = new("DropDownControl", {"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index) - timelessData.fallbackWeightMode.idx = index + local activeFallbackWeightIndex = timelessData.fallbackWeightMode.idx or 1 + if not fallbackWeightsList[activeFallbackWeightIndex] then + activeFallbackWeightIndex = 1 + end + local activeFallbackWeight = fallbackWeightsList[activeFallbackWeightIndex] + WeightedScore.appendEditWeightsAction(fallbackWeightsList, function() + controls.fallbackWeightsList:SelByValue(activeFallbackWeight.stat, "stat") + local tradeQuery = self.build.itemsTab.tradeQuery + if tradeQuery then + tradeQuery:SetStatWeights() + end + end) + controls.fallbackWeightsList = new("DropDownControl", {"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index, value) + if value.isAction then + value.action() + else + activeFallbackWeightIndex = index + activeFallbackWeight = value + timelessData.fallbackWeightMode.idx = index + end end) controls.fallbackWeightsLabel = new("LabelControl", {"RIGHT", controls.fallbackWeightsList, "LEFT"}, {-labelSpacing, 0, 0, labelHeight}, "^7Fallback Weight Mode:") - controls.fallbackWeightsList.selIndex = timelessData.fallbackWeightMode.idx or 1 + controls.fallbackWeightsList.selIndex = activeFallbackWeightIndex controls.fallbackWeightsButton = new("ButtonControl", {"LEFT", controls.fallbackWeightsList, "RIGHT"}, {5, 0, 66, 18}, "Generate", function() setupFallbackWeights() controls.searchListFallbackButton.label = "^4Fallback Nodes" diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index fd4fa2e137..ae791ba7bc 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 @@ -122,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 +205,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, @@ -217,7 +240,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) @@ -226,6 +249,23 @@ for i = 1, #data.powerStatList do t_insert(data.powerStatList, minionStat) ::statContinue:: end +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 + 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 new file mode 100644 index 0000000000..35a41f76cf --- /dev/null +++ b/src/Modules/WeightedScore.lua @@ -0,0 +1,115 @@ +-- 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 + +-- 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 * (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). +function WeightedScore.computeRatioScore(baseOutput, newOutput, weights) + local meanStatDiff = 0.0 + local function ratioModSums(...) + local baseModSum = 0 + local newModSum = 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 + 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 + local modSumRatio + if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then + -- FullDPS fallback: use combined DPS components when FullDPS is not directly available + modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) + else + modSumRatio = ratioModSums(statTable) + end + if statTable.transform then + modSumRatio = statTable.transform(modSumRatio) + end + meanStatDiff = meanStatDiff + modSumRatio * statTable.weightMult + end + return meanStatDiff +end + +-- 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 _, entry in ipairs(sortDropList) do + if entry.isWeightedScore then + table.insert(sortDropList, { + label = colorCodes.TIP .. "Edit Weights...", + isAction = true, + action = openEditor, + }) + return + end + 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