diff --git a/spec/System/TestAbyssTimelessJewel_spec.lua b/spec/System/TestAbyssTimelessJewel_spec.lua index 4ec1714b46..0ba84adbd7 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.stat == "WeightedScore" then + weightedIndex = index + weightedCount = weightedCount + 1 + end + end + assert.are.equal(1, weightedCount) + assert.is_truthy(weightedIndex) + assert.is_function(control.list[weightedIndex + 1].action) + assert.is_true(data.powerStatList.RequiresFullDPS(control.list[weightedIndex], build)) + + local opened = false + build.itemsTab.tradeQuery.SetStatWeights = function() + opened = true + 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 98d4605c4a..94ec9d995c 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -1,5 +1,12 @@ describe("ItemDBControl", function() local originalGetCursorPos + local function findPowerStat(statName) + for _, stat in ipairs(data.powerStatList) do + if stat.stat == statName then + return stat + end + end + end before_each(function() originalGetCursorPos = GetCursorPos @@ -62,6 +69,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"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { + list = { invalidItem, betterItem, worseItem }, + }, "RARE") + control.sortDetail = copyTable(findPowerStat("WeightedScore")) + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.equal(betterItem, control.list[1]) + assert.are.equal(worseItem, control.list[2]) + assert.are.equal(invalidItem, control.list[3]) + assert.is_true(betterItem.measuredPower < 0) + assert.is_true(worseItem.measuredPower < betterItem.measuredPower) + assert.are.equal(-math.huge, invalidItem.measuredPower) + assert.are.same({ false, false }, requestedFullDPS) + end) + + it("requests FullDPS for WeightedScore when active weights need it", function() + local item = { + name = "Full DPS Item", + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + local requestedFullDPS = { } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + tradeQuery = { statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } }, + IsItemValidForSlot = function() + return true + end, + } + itemsTab.build = { + itemsTab = itemsTab, + calcsTab = { + GetMiscCalculator = function() + return function(_, useFullDPS) + table.insert(requestedFullDPS, useFullDPS) + return { FullDPS = 120 } + end, { FullDPS = 100 } + end, + }, + } + local control = new("ItemDBControl"):ItemDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { list = { item } }, "RARE") + control.sortDetail = copyTable(findPowerStat("WeightedScore")) + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.same({ true }, requestedFullDPS) + end) + it("searches Foulborn modifier text without case sensitivity", function() local item = new("Item"):Item([[ Rarity: Unique diff --git a/spec/System/TestNotableDBControl_spec.lua b/spec/System/TestNotableDBControl_spec.lua new file mode 100644 index 0000000000..50bc940e30 --- /dev/null +++ b/spec/System/TestNotableDBControl_spec.lua @@ -0,0 +1,45 @@ +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"):NotableDBControl(nil, { 0, 0, 100, 100 }, itemsTab, { [1] = notable }, "ANOINT") + for _, stat in ipairs(data.powerStatList) do + if stat.stat == "WeightedScore" then + control.sortDetail = copyTable(stat) + break + end + end + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.same({ true, true }, requestedFullDPS) + assert.are.equal(notable, control.list[1]) + assert.is_true(notable.measuredPower > 0) + assert.are.equal(notable.measuredPower, control.sortMaxPower) + assert.are.equal("^xFF8080Full DPS Notable", control:GetRowValue(1, 1, notable)) + end) +end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4..51a4daaf2f 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"):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({}) + 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..fb05904f00 --- /dev/null +++ b/spec/System/TestWeightedScore_spec.lua @@ -0,0 +1,542 @@ +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 selects defaults or configured weights", function() + local custom = { { stat = "TotalDPS", label = "DPS", weightMult = 2.0 } } + local cases = { + { label = "nil build", expected = WeightedScore.defaultWeights() }, + { + label = "empty selection", + build = { itemsTab = { tradeQuery = { statSortSelectionList = {} } } }, + expected = WeightedScore.defaultWeights(), + }, + { + label = "configured selection", + build = { itemsTab = { tradeQuery = { statSortSelectionList = custom } } }, + expected = custom, + }, + } + + for _, case in ipairs(cases) do + assert.are.same(case.expected, WeightedScore.getWeights(case.build), case.label) + end + end) + + -- computeRatioScore: basic ranking ----------------------------------------- + + it("scores ordinary ratios and weight combinations", function() + local unitWeight = { { stat = "TotalDPS", weightMult = 1.0 } } + local cases = { + { label = "neutral", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1000 }, weights = unitWeight, expected = 1.0 }, + { label = "better", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 1500 }, weights = unitWeight, expected = 1.5 }, + { label = "worse", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 500 }, weights = unitWeight, expected = 0.5 }, + { + label = "multiple weighted stats", + base = { TotalDPS = 100, TotalEHP = 200 }, + candidate = { TotalDPS = 150, TotalEHP = 250 }, + weights = { + { stat = "TotalDPS", weightMult = 1.5 }, + { stat = "TotalEHP", weightMult = 0.25 }, + }, + expected = 2.5625, + }, + { label = "empty weights", base = { TotalDPS = 1000 }, candidate = { TotalDPS = 5000 }, weights = {}, expected = 0.0 }, + } + + for _, case in ipairs(cases) do + assert.are.equal( + case.expected, + WeightedScore.computeRatioScore(case.base, case.candidate, case.weights), + case.label + ) + end + end) + + -- computeRatioScore: edge cases -------------------------------------------- + + it("handles infinite, zero, and missing ratio inputs safely", function() + local weights = { { stat = "TotalDPS", weightMult = 1.0 } } + local cases = { + { label = "infinite base", base = { TotalDPS = math.huge }, candidate = { TotalDPS = 1000 }, expected = 0.0 }, + { label = "infinite candidate", base = { TotalDPS = 1000 }, candidate = { TotalDPS = math.huge }, expected = 2.0 }, + { label = "zero base", base = { TotalDPS = 0 }, candidate = { TotalDPS = 500 }, expected = 2.0 }, + { label = "missing stat", base = {}, candidate = {}, expected = 0.0 }, + } + + for _, case in ipairs(cases) do + assert.are.equal( + case.expected, + WeightedScore.computeRatioScore(case.base, case.candidate, weights), + case.label + ) + end + end) + + -- computeRatioScore: FullDPS fallback -------------------------------------- + + 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) + + -- weightsRequireFullDPS: FullDPS requirement used by PowerBuilder ---------- + + it("weightsRequireFullDPS identifies active FullDPS weights", function() + local cases = { + { label = "nil", expected = false }, + { label = "empty", weights = {}, expected = false }, + { label = "only FullDPS", weights = { { stat = "FullDPS", weightMult = 1.0 } }, expected = true }, + { + label = "ordinary stats", + weights = { { stat = "TotalEHP", weightMult = 0.5 }, { stat = "TotalDPS", weightMult = 1.0 } }, + expected = false, + }, + { + label = "FullDPS among other stats", + weights = { + { stat = "TotalEHP", weightMult = 0.5 }, + { stat = "FullDPS", weightMult = 1.0 }, + { stat = "Life", weightMult = 0.25 }, + }, + expected = true, + }, + { label = "zero FullDPS weight", weights = { { stat = "FullDPS", weightMult = 0 } }, expected = false }, + { label = "custom stat", weights = { { stat = "TotalAttr", weightMult = 1.0 } }, expected = false }, + } + + for _, case in ipairs(cases) do + assert.are.equal(case.expected, WeightedScore.weightsRequireFullDPS(case.weights), case.label) + end + end) +end) + +describe("WeightedScore — TradeQueryGenerator delegation", function() + local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ + itemsTab = {}, + GetTradeStatusOption = function() return "online" end, + }) + + it("WeightedRatioOutputs delegates ratio calculation and preserves candidate ranking", 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) + + base = { TotalDPS = 1000 } + local high = { TotalDPS = 1500 } + local low = { TotalDPS = 800 } + weights = { { stat = "TotalDPS", weightMult = 1.0 } } + + local highScore = mock_queryGen.WeightedRatioOutputs(base, high, weights) + local lowScore = mock_queryGen.WeightedRatioOutputs(base, low, weights) + assert.is_true(highScore > lowScore) + end) +end) + +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 maxIterations = 100000 + local iterations = 0 + repeat + build.calcsTab:BuildPower() + iterations = iterations + 1 + until not build.calcsTab.powerBuilder or iterations >= maxIterations + end + + it("registers WeightedScore as the final shared non-minion power stat", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + assert.are.equal("WeightedScore", data.powerStatList[#data.powerStatList].stat) + assert.is_nil(findStat("MinionWeightedScore")) + end) + + it("power builder completes with an initialized non-negative WeightedScore result", function() + local stat = findStat("WeightedScore") + assert.is_not_nil(stat) + drainPowerBuild(stat) + assert.is_true(build.calcsTab.powerBuilderInitialized) + 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) + + -- Fallback weights must evaluate the configured stats rather than a synthetic output key. + it("power stat helpers keep positive WeightedScore deltas and baselines on the same scale", function() + local weightedScore = findStat("WeightedScore") + local fullDPS = findStat("FullDPS") + 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)) + local baseValue = data.powerStatList.GetValue(baseOutput, weightedScore, build, baseOutput) + local betterValue = data.powerStatList.GetValue(betterOutput, weightedScore, build, baseOutput) + local delta = build.calcsTab:CalculatePowerStat(weightedScore, betterOutput, baseOutput) + assert.is_true(math.abs(baseValue - 1500) < 0.0001) + assert.is_true(math.abs(betterValue - 1700) < 0.0001) + assert.is_true(betterValue > baseValue) + assert.is_true(math.abs(delta - 200) < 0.0001) + assert.is_true(math.abs(delta / baseValue - 2 / 15) < 0.0001) + assert.are.equal(123, data.powerStatList.GetValue({ Life = 123 }, life, build, baseOutput)) + 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) + + 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 ----------------------------------------------- + local function buildWithWeightEditor(openEditor) + return { itemsTab = { tradeQuery = { + SetStatWeights = function(_, previousSelection, onSave) + openEditor(previousSelection, onSave) + end, + } } } + end + + it("appendEditWeightsAction handles lists with and without WeightedScore", function() + local ordinaryList = { + { label = "Sort by Name", sortMode = "name" }, + { label = "Sort by Life", sortMode = "Life" }, + } + local called = false + WeightedScore.appendEditWeightsAction(ordinaryList, function() called = true end) + assert.are.equal(2, #ordinaryList) + assert.is_false(called) + + local list = { + { label = "Sort by Name", sortMode = "name" }, + { label = "Sort by Weighted Score", sortMode = "WeightedScore", stat = "WeightedScore" }, + } + local opened = false + WeightedScore.appendEditWeightsAction(list, function() opened = true end) + assert.are.equal(3, #list) + local entry = list[3] + assert.is_function(entry.action) + assert.is_string(entry.label) + assert.are.equal("WeightedScore", list[2].stat) + 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" }, + } + 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 build = buildWithWeightEditor(function(_, onSave) + weight = "defence" + onSave() + end) + local handler = WeightedScore.createSortHandler(list, controls, build, 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.stat == "WeightedScore" 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_function(list[weightedIndex + 1].action) + 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 baseline-dependent scores with their reference output 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 baseline-dependent stats through GetValue") + assert.is_true(sawBaseline, "baseline-dependent scoring must receive the item without the candidate affix") + assert.is_true(#useFullDPSCalls > 0) + for _, useFullDPS in ipairs(useFullDPSCalls) do + assert.is_true(useFullDPS) + end + assert.is_not_nil(control.list[2].modList) + local highestScoredMod = itemsTab.displayItem.affixes[control.list[2].modList[1]] + assert.are.equal(2, #highestScoredMod, "the highest baseline-dependent score must sort first") + end) +end) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 7af7c72e63..d184072546 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -495,7 +495,7 @@ end -- Estimate the offensive and defensive power of all unallocated nodes function CalcsTabClass:PowerBuilder() -- local timer_start = GetTime() - local useFullDPS = self.powerStat and self.powerStat.stat == "FullDPS" + local useFullDPS = self.powerStat and data.powerStatList.RequiresFullDPS(self.powerStat, self.build) local calcFunc, calcBase = self:GetMiscCalculator() local cache = { } local distanceMap = { } @@ -740,8 +740,8 @@ function CalcsTabClass:PowerBuilder() end function CalcsTabClass:CalculatePowerStat(selection, original, modified) - local originalValue = data.powerStatList.GetFromOutput(original, selection) - local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) + local originalValue = data.powerStatList.GetValue(original, selection, self.build, modified) + local modifiedValue = data.powerStatList.GetValue(modified, selection, self.build, modified) return originalValue - modifiedValue end diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index eca7261903..fa3ed7b645 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 @@ -977,8 +978,18 @@ function CompareTabClass:InitControls() t_insert(powerStatList, entry) end end + WeightedScore.appendEditWeightsAction(powerStatList, function() + self.controls.comparePowerStatSelect:SelByValue(self.comparePowerStat and self.comparePowerStat.stat, "stat") + WeightedScore.editWeights(self.primaryBuild, function() + if self.comparePowerStat and self.comparePowerStat.stat == "WeightedScore" then + self.comparePowerDirty = true + end + end) + end) self.controls.comparePowerStatSelect = new("DropDownControl"):DropDownControl(nil, {0, 0, 200, 20}, powerStatList, function(index, value) - if value and value.stat and value ~= self.comparePowerStat then + if value and value.action 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 @@ -2491,10 +2502,17 @@ end -- Coroutine: calculate power of compared build elements against primary build function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories) local results = {} - local useFullDPS = powerStat.stat == "FullDPS" + local useFullDPS = data.powerStatList.RequiresFullDPS(powerStat, self.primaryBuild) + local function getPowerCalculator() + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + if useFullDPS then + calcBase = calcFunc(nil, true) + end + return calcFunc, calcBase + end -- Get calculator for primary build - local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + local calcFunc, calcBase = getPowerCalculator() -- Find display stat for formatting local displayStat = nil @@ -2597,7 +2615,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- Get baseline stat value for percentage calculation - local baseStatValue = data.powerStatList.GetFromOutput(calcBase, powerStat) + local baseStatValue = data.powerStatList.GetValue(calcBase, powerStat, self.primaryBuild, calcBase) -- Helper to format an impact value and compute percentage local function formatImpact(impact) @@ -2893,9 +2911,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories self.primaryBuild.buildFlag = true -- Get a fresh calculator with the added group (pcall to guarantee cleanup) - local ok, gemCalcFunc, gemCalcBase = pcall(function() - return self.calcs.getMiscCalculator(self.primaryBuild) - end) + local ok, gemCalcFunc, gemCalcBase = pcall(getPowerCalculator) -- Always remove the temporarily added group t_remove(pGroups) @@ -2976,9 +2992,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories t_insert(pMainGroup.gemList, tempGem) self.primaryBuild.buildFlag = true - local ok, sgCalcFunc, sgCalcBase = pcall(function() - return self.calcs.getMiscCalculator(self.primaryBuild) - end) + local ok, sgCalcFunc, sgCalcBase = pcall(getPowerCalculator) -- Always remove the temporarily added gem t_remove(pMainGroup.gemList) @@ -3046,7 +3060,7 @@ 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) + return getPowerCalculator() end) -- Always restore original value diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 609aa5a3aa..b721ed07c0 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -8,7 +8,7 @@ local ipairs = ipairs local t_insert = table.insert local m_max = math.max local m_floor = math.floor - +local WeightedScore = LoadModule("Modules/WeightedScore") ---@class ItemDBControl: ListControl local ItemDBClass = newClass("ItemDBControl", "ListControl") @@ -39,7 +39,12 @@ function ItemDBClass:ItemDBControl(anchor, rect, itemsTab, db, dbType) end) if dbType == "UNIQUE" then self.controls.sort = new("DropDownControl"):DropDownControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, baseY + 20, 179, 18}, self.sortDropList, function(index, value) - self:SetSortMode(value.sortMode) + if value.action then + value.action() + self.controls.sort:SelByValue(self.sortMode, "sortMode") + else + self:SetSortMode(value.sortMode) + end end) self.controls.league = new("DropDownControl"):DropDownControl({"LEFT",self.controls.sort,"RIGHT"}, {2, 0, 179, 18}, self.leagueList, function(index, value) self.listBuildFlag = true @@ -210,15 +215,15 @@ function ItemDBClass:BuildSortOrder() wipeTable(self.sortDropList) for id, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems then - t_insert(self.sortDropList, { - label="Sort by "..stat.label, - sortMode=stat.itemField or stat.stat, - itemField=stat.itemField, - stat=stat.stat, - transform=stat.transform, - }) + local sortEntry = copyTable(stat) + sortEntry.label = "Sort by " .. stat.label + sortEntry.sortMode = stat.itemField or stat.stat + t_insert(self.sortDropList, sortEntry) end end + WeightedScore.appendEditWeightsAction(self.sortDropList, function() + WeightedScore.editWeights(self.itemsTab.build, function() self.listBuildFlag = true end) + end) wipeTable(self.sortOrder) if self.controls.sort then self.controls.sort:CheckDroppedWidth(true) @@ -241,7 +246,7 @@ function ItemDBClass:ListBuilder() end if self.sortDetail and self.sortDetail.stat then -- stat-based - local useFullDPS = self.sortDetail.stat == "FullDPS" + local useFullDPS = data.powerStatList.RequiresFullDPS(self.sortDetail, self.itemsTab.build) local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) for itemIndex, item in ipairs(list) do @@ -249,7 +254,7 @@ function ItemDBClass:ListBuilder() for slotName, slot in pairs(self.itemsTab.slots) do if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }, useFullDPS) - local measuredPower = data.powerStatList.GetFromOutput(output, self.sortDetail) + local measuredPower = data.powerStatList.GetValue(output, self.sortDetail, self.itemsTab.build, calcBase) item.measuredPower = m_max(item.measuredPower, measuredPower) end end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 3ae6e046aa..fcb19bfe38 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 = { @@ -78,6 +79,14 @@ local function buildModSortList() return sortList, sortStats end +local function getCandidateSortContext(itemsTab, statEntry) + local slotName = itemsTab.displayItem:GetPrimarySlot() + local useFullDPS = data.powerStatList.RequiresFullDPS(statEntry, itemsTab.build) + local calcFunc = itemsTab.build.calcsTab:GetMiscCalculator() + local calcBase = calcFunc({ repSlotName = slotName, repItem = itemsTab.displayItem }, useFullDPS) + return calcFunc, calcBase, slotName, useFullDPS +end + ---@class ItemsTab: UndoHandler, ControlHost, Control local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Control") @@ -654,6 +663,13 @@ 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") + WeightedScore.editWeights(self.build, function() + self:UpdateAffixControls() + end) + end) -- Section: Catalysts self.controls.displayItemSectionCatalyst = new("Control"):Control({"TOPLEFT",self.controls.displayItemSectionQuality,"BOTTOMLEFT"}, {0, 0, 0, function() return (self.controls.displayItemCatalyst:IsShown() or self.controls.displayItemCatalystQualityEdit:IsShown()) and 28 or 0 @@ -723,8 +739,13 @@ holding Shift will put it in the second.]]) -- cluster jewels don't have good comparison support and sorting would be misleading not (self.displayItem.base.type == "Jewel" and self.displayItem.base.subType == "Cluster") end - self.controls.craftingSorting = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function() - self:UpdateAffixControls() + self.controls.craftingSorting = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.craftingSortingLabel, "RIGHT" }, { 4, 0, 200, 20 }, sortingOptions, function(index, value) + if value.action then + value.action() + else + activeCraftingSort = value + self:UpdateAffixControls() + end end) -- Section: Affix Selection @@ -2225,6 +2246,7 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, if sortOption.stat and self.controls.craftingSortingLabel.shown() then local calcFunc = self.build.calcsTab:GetMiscCalculator() local slotName = self.displayItem:GetPrimarySlot() + local useFullDPS = data.powerStatList.RequiresFullDPS(sortOption, self.build) local testSubject = new("Item"):Item(self.displayItem:BuildRaw()) local controlPowerCache = powerCache if selAffix and selAffix ~= "None" then @@ -2232,6 +2254,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 @@ -2260,9 +2284,11 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, t_insert(testSubject.explicitModLines, modLine) end testSubject:BuildAndParseRaw() - power = data.powerStatList.GetFromOutput( - calcFunc({ repSlotName = slotName, repItem = testSubject }), - sortOption + power = data.powerStatList.GetValue( + calcFunc({ repSlotName = slotName, repItem = testSubject }, useFullDPS), + sortOption, + self.build, + calcBase ) testSubject = new("Item"):Item(originalItem) else @@ -2274,9 +2300,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) @@ -2755,7 +2783,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) item:BuildAndParseRaw() return item end - local function getSortValue(entry, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(entry, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) entry.sortValues = entry.sortValues or { } if entry.sortValues[stat] ~= nil then return entry.sortValues[stat] @@ -2777,7 +2805,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) entry.sortValues[stat] = value return value end @@ -2787,11 +2815,10 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end local selected = not selectFirst and enchantmentList[controls.enchantment.selIndex] or nil if stat then - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = self.build.calcsTab:GetMiscCalculator() - local useFullDPS = stat == "FullDPS" + local statEntry = sortStats[stat] + local calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) for _, entry in ipairs(enchantmentList) do - entry.sortValue = getSortValue(entry, stat, calcFunc, slotName, useFullDPS) + entry.sortValue = getSortValue(entry, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(enchantmentList, function(a, b) if a.sortValue ~= b.sortValue then @@ -2816,6 +2843,12 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) controls.enchantment:SetSel(1, true) end end + local function clearSortValues() + for _, entry in ipairs(enchantmentList) do + entry.sortValue = nil + entry.sortValues = nil + end + end if haveSkills then controls.skillLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {95, 20, 0, 16}, "^7Skill:") controls.skill = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {100, 20, 180, 18}, skillList, function(index, value) @@ -2850,9 +2883,8 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end end) controls.sortLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {350, 45, 0, 16}, "^7Sort by:") - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 45, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 45, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) controls.enchantmentLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {95, 70, 0, 16}, "^7Enchantment:") controls.enchantment = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {100, 70, 495, 18}, enchantmentList) controls.enchantment.tooltipFunc = function(tooltip, mode, index) @@ -3065,7 +3097,7 @@ function ItemsTabClass:CorruptDisplayItem(modType) end control:SelByValue(selfMod, "mod") end - local function getSortValue(entry, modType, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(entry, modType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) entry.sortValues = entry.sortValues or { } if entry.sortValues[stat] ~= nil then return entry.sortValues[stat] @@ -3086,17 +3118,17 @@ function ItemsTabClass:CorruptDisplayItem(modType) end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) entry.sortValues[stat] = value return value end - local function sortModType(modType, stat, calcFunc, slotName, useFullDPS) + local function sortModType(modType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) if not implicitList[modType] then return end if stat then for _, entry in ipairs(implicitList[modType]) do - entry.sortValue = getSortValue(entry, modType, stat, calcFunc, slotName, useFullDPS) + entry.sortValue = getSortValue(entry, modType, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(implicitList[modType], function(a, b) if a.sortValue ~= b.sortValue then @@ -3114,14 +3146,17 @@ function ItemsTabClass:CorruptDisplayItem(modType) if not controls.implicit1 then return end - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = stat and self.build.calcsTab:GetMiscCalculator() or nil - local useFullDPS = stat == "FullDPS" + local statEntry + local calcFunc, calcBase, slotName, useFullDPS + if stat then + statEntry = sortStats[stat] + calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) + end if currentModType == "Corrupted" then - sortModType("Corrupted", stat, calcFunc, slotName, useFullDPS) + sortModType("Corrupted", stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) else - sortModType("ScourgeUpside", stat, calcFunc, slotName, useFullDPS) - sortModType("ScourgeDownside", stat, calcFunc, slotName, useFullDPS) + sortModType("ScourgeUpside", stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) + sortModType("ScourgeDownside", stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end if currentModType == "Corrupted" then buildList(controls.implicit1, controls.implicit2, currentModType) @@ -3137,6 +3172,14 @@ function ItemsTabClass:CorruptDisplayItem(modType) if controls.implicit3 then controls.implicit3:UpdateSearch() end if controls.implicit4 then controls.implicit4:UpdateSearch() end end + local function clearSortValues() + for _, entries in pairs(implicitList) do + for _, entry in ipairs(entries) do + entry.sortValue = nil + entry.sortValues = nil + end + end + end local function corruptItem(addingImplicits) local item = new("Item"):Item(self.displayItem:BuildRaw()) item.id = self.displayItem.id @@ -3285,12 +3328,11 @@ function ItemsTabClass:CorruptDisplayItem(modType) controls.implicit2:SetSel(1) controls.implicit3:SetSel(1) controls.implicit4:SetSel(1) - end) + end) controls.source.enabled = #sourceList > 1 controls.sortLabel = new("LabelControl"):LabelControl({"TOPRIGHT",nil,"TOPLEFT"}, {350, 20, 0, 16}, "^7Sort by:") - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) local implicitRowSize = 20 local implicitYPos = 35 controls.implicitCannotBeChangedLabel = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 20, implicitYPos + implicitRowSize, 0, 20 }, "^7This Items Implicits Cannot Be Changed") @@ -3355,7 +3397,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() listMod.sortValues = nil end end - local function getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) listMod.sortValues = listMod.sortValues or { } if listMod.sortValues[stat] ~= nil then return listMod.sortValues[stat] @@ -3367,7 +3409,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) listMod.sortValues[stat] = value return value end @@ -3377,11 +3419,10 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() end local selected = not selectFirst and modList[controls.modSelect.selIndex] or nil if stat then - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = self.build.calcsTab:GetMiscCalculator() - local useFullDPS = stat == "FullDPS" + local statEntry = sortStats[stat] + local calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) for _, listMod in ipairs(modList) do - listMod.sortValue = getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + listMod.sortValue = getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(modList, function(a, b) if a.sortValue ~= b.sortValue then @@ -3406,6 +3447,12 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() controls.modSelect:SetSel(1, true) end end + local function clearSortValues() + for _, listMod in ipairs(modList) do + listMod.sortValue = nil + listMod.sortValues = nil + end + end ---Mutates modList to contain mods from the specified source ---@param sourceId string @The crafting source id to build the list of mods for local function buildMods(sourceId) @@ -3622,9 +3669,8 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() controls.sortLabel.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) controls.sort.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end @@ -3956,7 +4002,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() t_insert(item.implicitModLines, { line = line, modTags = listMod.mod.modTags, [listMod.type] = true }) end end - local function getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + local function getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) listMod.sortValues = listMod.sortValues or { } if listMod.sortValues[stat] ~= nil then return listMod.sortValues[stat] @@ -3966,7 +4012,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() applyCandidateMod(item, listMod) item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) + local value = data.powerStatList.GetValue(output, statEntry, self.build, calcBase) listMod.sortValues[stat] = value return value end @@ -3977,12 +4023,11 @@ function ItemsTabClass:AddImplicitToDisplayItem() local selectedGroup = not selectFirst and modGroups[controls.modGroupSelect.selIndex] or nil local selectedMod = not selectFirst and controls.modSelect.list and controls.modSelect.list[controls.modSelect.selIndex] or nil if stat then - local slotName = self.displayItem:GetPrimarySlot() - local calcFunc = self.build.calcsTab:GetMiscCalculator() - local useFullDPS = stat == "FullDPS" + local statEntry = sortStats[stat] + local calcFunc, calcBase, slotName, useFullDPS = getCandidateSortContext(self, statEntry) for _, listMods in ipairs(modList) do for _, listMod in ipairs(listMods) do - listMod.sortValue = getSortValue(listMod, stat, calcFunc, slotName, useFullDPS) + listMod.sortValue = getSortValue(listMod, stat, statEntry, calcFunc, calcBase, slotName, useFullDPS) end table.sort(listMods, function(a, b) if a.sortValue ~= b.sortValue then @@ -4040,6 +4085,17 @@ function ItemsTabClass:AddImplicitToDisplayItem() controls.modSelect:SetSel(1, true) end end + local function clearSortValues() + for _, group in ipairs(modGroups) do + group.sortValue = nil + end + for _, listMods in ipairs(modList) do + for _, listMod in ipairs(listMods) do + listMod.sortValue = nil + listMod.sortValues = nil + end + end + end local function addModifier() local item = new("Item"):Item(self.displayItem:BuildRaw()) item.id = self.displayItem.id @@ -4076,9 +4132,8 @@ function ItemsTabClass:AddImplicitToDisplayItem() controls.sortLabel.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end - controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, function(index, value) - applySort(value.stat, true) - end) + controls.sort = new("DropDownControl"):DropDownControl({"TOPLEFT",nil,"TOPLEFT"}, {355, 20, 240, 18}, sortList, + WeightedScore.createSortHandler(sortList, controls, self.build, applySort, clearSortValues)) controls.sort.shown = function() return sourceList[controls.source.selIndex].sourceId ~= "CUSTOM" end diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index 4216c22be6..f81df3bd70 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 @@ -35,7 +36,12 @@ function NotableDBClass:NotableDBControl(anchor, rect, itemsTab, db, dbType) self.sortOrder = { } self.sortMode = "NAME" self.controls.sort = new("DropDownControl"):DropDownControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -22, 360, 18}, self.sortDropList, function(index, value) - self:SetSortMode(value.sortMode) + if value.action then + value.action() + self.controls.sort:SelByValue(self.sortMode, "sortMode") + else + self:SetSortMode(value.sortMode) + end end) self.controls.search = new("EditControl"):EditControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -2, 258, 18}, "", "Search", "%c", 100, function() self.listBuildFlag = true @@ -92,15 +98,15 @@ function NotableDBClass:BuildSortOrder() wipeTable(self.sortDropList) for id, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems then - t_insert(self.sortDropList, { - label="Sort by "..stat.label, - sortMode=stat.itemField or stat.stat, - itemField=stat.itemField, - stat=stat.stat, - transform=stat.transform, - }) + local sortEntry = copyTable(stat) + sortEntry.label = "Sort by " .. stat.label + sortEntry.sortMode = stat.itemField or stat.stat + t_insert(self.sortDropList, sortEntry) end end + WeightedScore.appendEditWeightsAction(self.sortDropList, function() + WeightedScore.editWeights(self.itemsTab.build, function() self.listBuildFlag = true end) + end) wipeTable(self.sortOrder) if self.controls.sort then self.controls.sort.selIndex = 1 @@ -114,6 +120,9 @@ function NotableDBClass:BuildSortOrder() end function NotableDBClass:CalculatePowerStat(selection, original, modified) + if selection.getValue then + return data.powerStatList.GetValue(original, selection, self.itemsTab.build, modified) + end local originalValue = data.powerStatList.GetFromOutput(original, selection) local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) return originalValue - modifiedValue @@ -133,12 +142,13 @@ 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 useFullDPS = data.powerStatList.RequiresFullDPS(self.sortDetail, self.itemsTab.build) + local calcBase = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(nil) }, useFullDPS) self.sortMaxPower = 0 for nodeIndex, node in ipairs(list) do node.measuredPower = 0 if node.modKey ~= "" then - local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }) + local output = calcFunc({ repSlotName = itemType, repItem = self.itemsTab:anointItem(node) }, useFullDPS) node.measuredPower = self:CalculatePowerStat(self.sortDetail, output, calcBase) if node.measuredPower == m_huge then t_insert(infinites, node) @@ -290,4 +300,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/TradeQuery.lua b/src/Classes/TradeQuery.lua index 7ea2fcedf0..8d9177b89e 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") +local WeightedScore = LoadModule("Modules/WeightedScore") local get_time = os.time local t_insert = table.insert @@ -254,23 +255,24 @@ function TradeQueryClass:PullCXData() end local function initStatSortSelectionList(list) - t_insert(list, { - label = "Full DPS", - stat = "FullDPS", - weightMult = 1.0, - }) - t_insert(list, { - label = "Effective Hit Pool", - stat = "TotalEHP", - weightMult = 0.5, - }) + for _, weight in ipairs(WeightedScore.defaultWeights()) do + t_insert(list, weight) + end end -- we do not want to overwrite previous list if the new list is the default, e.g. hitting reset multiple times in a row local function isSameAsDefaultList(list) - return list and #list == 2 - and list[1].stat == "FullDPS" and list[1].weightMult == 1.0 - and list[2].stat == "TotalEHP" and list[2].weightMult == 0.5 + local defaultWeights = WeightedScore.defaultWeights() + if not list or #list ~= #defaultWeights then + return false + end + for index, weight in ipairs(defaultWeights) do + local selectedWeight = list[index] + if selectedWeight.stat ~= weight.stat or selectedWeight.weightMult ~= weight.weightMult then + return false + end + end + return true end -- Opens the item pricing popup @@ -666,8 +668,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 } @@ -681,7 +687,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 stat.stat ~= "WeightedScore" then t_insert(statList, { label = "0 : "..stat.label, stat = { @@ -745,10 +751,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"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() if previousSelectionList and #previousSelectionList > 0 then @@ -766,7 +774,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 9b7e3f5b40..e5fde327c6 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -5,6 +5,7 @@ -- local dkjson = require "dkjson" +local WeightedScore = LoadModule("Modules/WeightedScore") local curl = require("lcurl.safe") local m_max = math.max local s_format = string.format @@ -168,40 +169,7 @@ function TradeQueryGeneratorClass:TradeQueryGenerator(queryTab) end function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, statWeights) - local meanStatDiff = 0 - - local function ratioModSums(...) - local baseModSum = 0 - local newModSum = 0 - for _, mod in ipairs({ ... }) do - baseModSum = baseModSum + data.powerStatList.GetFromOutput(baseOutput, mod, true) - newModSum = newModSum + data.powerStatList.GetFromOutput(newOutput, mod, true) - end - - if baseModSum == math.huge then - return 0 - else - if newModSum == math.huge then - return data.misc.maxStatIncrease - else - return math.min(newModSum / ((baseModSum ~= 0) and baseModSum or 1), data.misc.maxStatIncrease) - end - end - end - for _, statTable in ipairs(statWeights) do - local modSumRatio - if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then - modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) - else - modSumRatio = ratioModSums(statTable) - end - -- some weights, such as damage taken from hit need to be negated as lower is better for them - if statTable.transform then - modSumRatio = statTable.transform(modSumRatio) - end - meanStatDiff = meanStatDiff + modSumRatio * statTable.weightMult - end - return meanStatDiff + return WeightedScore.computeRatioScore(baseOutput, newOutput, statWeights) end function TradeQueryGeneratorClass:ProcessMod(modId, mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index c9cb48c211..79e6a8c116 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" @@ -254,7 +255,14 @@ function TreeTabClass:TreeTab(build) -- Control for selecting the power stat to sort by (Defense, DPS, etc) self.controls.treeHeatMapStatSelect = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT" }, { 8, 0, 150, 20 }, nil, function(index, value) - self:SetPowerCalc(value) + if value.action 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+)") @@ -267,6 +275,9 @@ function TreeTabClass:TreeTab(build) t_insert(self.powerStatList, stat) end end + WeightedScore.appendEditWeightsAction(self.powerStatList, function() + WeightedScore.editWeights(self.build, function() self:SetPowerCalc(self.build.calcsTab.powerStat) end) + end) -- Show/Hide Power Report Button self.controls.powerReport = new("ButtonControl"):ButtonControl({ "LEFT", self.controls.treeHeatMapStatSelect, "RIGHT" }, { 8, 0, 150, 20 }, @@ -1933,16 +1944,23 @@ 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) + return data.powerStatList.GetValue(output, powerStat, self.build, calcBase) + 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 @@ -2102,18 +2120,31 @@ function TreeTabClass:FindTimelessJewel() local fallbackWeightsList = { } for _, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems and stat.label ~= "Name" then - t_insert(fallbackWeightsList, { - label = "Sort by " .. stat.label, - stat = stat.stat, - transform = stat.transform, - }) + local fallbackWeight = copyTable(stat) + fallbackWeight.label = "Sort by " .. stat.label + t_insert(fallbackWeightsList, fallbackWeight) end end - controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index) - timelessData.fallbackWeightMode.idx = index + local selectedFallbackStatIndex = timelessData.fallbackWeightMode.idx or 1 + if not fallbackWeightsList[selectedFallbackStatIndex] then + selectedFallbackStatIndex = 1 + end + local selectedFallbackStat = fallbackWeightsList[selectedFallbackStatIndex] + WeightedScore.appendEditWeightsAction(fallbackWeightsList, function() + controls.fallbackWeightsList:SelByValue(selectedFallbackStat.stat, "stat") + WeightedScore.editWeights(self.build) + end) + controls.fallbackWeightsList = new("DropDownControl"):DropDownControl({"TOPLEFT", controls.nodeSelect, "BOTTOMLEFT"}, {0, rowSpacing, 200, rowHeight}, fallbackWeightsList, function(index, value) + if value.action then + value.action() + else + selectedFallbackStatIndex = index + selectedFallbackStat = value + timelessData.fallbackWeightMode.idx = index + end end) controls.fallbackWeightsLabel = new("LabelControl"):LabelControl({"RIGHT", controls.fallbackWeightsList, "LEFT"}, {-labelSpacing, 0, 0, labelHeight}, "^7Fallback Weight Mode:") - controls.fallbackWeightsList.selIndex = timelessData.fallbackWeightMode.idx or 1 + controls.fallbackWeightsList.selIndex = selectedFallbackStatIndex controls.fallbackWeightsButton = new("ButtonControl"):ButtonControl({"LEFT", controls.fallbackWeightsList, "RIGHT"}, {5, 0, 66, 18}, "Generate", function() setupFallbackWeights() controls.searchListFallbackButton.label = "^4Fallback Nodes" diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 30202c4609..565b20aa90 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 @@ -123,12 +124,15 @@ end ---@field ignoreForNodes? boolean ---@field ignoreForItems? boolean ---@field reverseSort? boolean +---@field itemField? string +---@field requiresFullDPS? boolean|fun(build?: table): boolean +---@field getValue? fun(output: any, build?: table, calcBase?: table): number ---@type StatTable[] data.powerStatList = { { 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" }, @@ -210,6 +214,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, @@ -223,7 +249,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.stat == "WeightedScore" or statEntry.stat:match("DPS") or minionNonApplicableStats[statEntry.stat] then goto statContinue end local minionStat = copyTable(statEntry) @@ -232,6 +258,24 @@ for i = 1, #data.powerStatList do t_insert(data.powerStatList, minionStat) ::statContinue:: end +t_insert(data.powerStatList, { + stat="WeightedScore", + label="Weighted Score", + requiresFullDPS=function(build) + return WeightedScore.weightsRequireFullDPS(WeightedScore.getWeights(build)) + end, + getValue=function(output, build, calcBase) + local weights = WeightedScore.getWeights(build) + local baselineOutput = calcBase + if not baselineOutput then + local _, cachedBaselineOutput = build.calcsTab:GetMiscCalculator() + baselineOutput = cachedBaselineOutput + end + -- Trader multiplies ratio scores by 1000 to create weight points. Reuse it so + -- Calcs/Compare deltas and every ranking or normalization preserve one scale. + return WeightedScore.computeRatioScore(baselineOutput, output, weights) * 1000 + end, +}) data.misc = { -- magic numbers 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..644b0c63ad --- /dev/null +++ b/src/Modules/WeightedScore.lua @@ -0,0 +1,124 @@ +-- Path of Building +-- +-- Module: Weighted Score +-- Shared weighted stat score computation and weight management for stat-based ranking. +-- + +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 tradeQuery = build and build.itemsTab and build.itemsTab.tradeQuery + if tradeQuery and tradeQuery.statSortSelectionList and #tradeQuery.statSortSelectionList > 0 then + return tradeQuery.statSortSelectionList + end + return WeightedScore.defaultWeights() +end + +-- Open the shared Trade Query weight editor. onSave only runs after Save, so +-- consumers can invalidate derived scores without reacting to Cancel. +function WeightedScore.editWeights(build, onSave) + local tradeQuery = build and build.itemsTab and build.itemsTab.tradeQuery + if tradeQuery then + tradeQuery:SetStatWeights(nil, onSave) + end +end + +-- Returns true when any active weight targets FullDPS, so callers can route +-- through the FullDPS-aware calculation path. +function WeightedScore.weightsRequireFullDPS(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 weightedScore = 0.0 + local function computeStatSumRatio(...) + local baseStatSum = 0 + local candidateStatSum = 0 + for _, statTable in ipairs({ ... }) do + baseStatSum = baseStatSum + data.powerStatList.GetFromOutput(baseOutput, statTable, true) + candidateStatSum = candidateStatSum + data.powerStatList.GetFromOutput(newOutput, statTable, true) + end + if baseStatSum == math.huge then + return 0 + elseif candidateStatSum == math.huge then + return data.misc.maxStatIncrease + else + return math.min(candidateStatSum / ((baseStatSum ~= 0) and baseStatSum or 1), data.misc.maxStatIncrease) + end + end + for _, statTable in ipairs(weights) do + local statSumRatio + if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then + -- FullDPS fallback: use combined DPS components when FullDPS is not directly available + statSumRatio = computeStatSumRatio({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) + else + statSumRatio = computeStatSumRatio(statTable) + end + if statTable.transform then + statSumRatio = statTable.transform(statSumRatio) + end + weightedScore = weightedScore + statSumRatio * statTable.weightMult + end + return weightedScore +end + +-- Append "Edit Weights..." after WeightedScore so the +-- score remains the final metric while its configuration stays adjacent. +function WeightedScore.appendEditWeightsAction(sortDropList, openEditor) + for _, entry in ipairs(sortDropList) do + if entry.stat == "WeightedScore" then + table.insert(sortDropList, { + label = colorCodes.TIP .. "Edit Weights...", + action = openEditor, + }) + return + end + end +end + +-- Keep the selected metric while opening the editor. Saving clears cached +-- candidate scores before reapplying that metric; cancelling leaves them intact. +function WeightedScore.createSortHandler(sortDropList, controls, build, applySort, clearSortValues) + local activeSort = sortDropList[1] + WeightedScore.appendEditWeightsAction(sortDropList, function() + controls.sort:SelByValue(activeSort.stat, "stat") + WeightedScore.editWeights(build, function() + clearSortValues() + applySort(activeSort.stat, true) + end) + end) + return function(index, value) + if value.action then + value.action() + else + activeSort = value + applySort(value.stat, true) + end + end +end + +return WeightedScore