Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions spec/System/TestTradeQueryGenerator_spec.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
local dkjson = require "dkjson"

describe("TradeQueryGenerator", function()
local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} })

local function findStatFilter(queryTable, id, groupType)
for _, group in ipairs(queryTable.query.stats) do
for _, filter in ipairs(group.filters) do
if filter.id == id and (not groupType or group.type == groupType) then
return filter
end
end
end
end

local function finishQueryWithAttributeMinimums(minimums, includeAttributeRequirementFilters)
local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} })
local queryTable, errMsg
queryGen.modWeights = {
{ tradeModId = "explicit.stat_3299347043", weight = 1, meanStatDiff = 1 },
}
queryGen.tradeTypeIndex = 1
queryGen.requesterCallback = function(_, queryJson, queryErrMsg)
queryTable = dkjson.decode(queryJson)
errMsg = queryErrMsg
end
queryGen.calcContext = {
itemCategoryQueryStr = "accessory.ring",
special = {},
testItem = { BuildAndParseRaw = function() end },
baseOutput = { TotalDPS = 100 },
options = {
statWeights = { { stat = "TotalDPS", weightMult = 1 } },
includeAttributeRequirementFilters = includeAttributeRequirementFilters,
includeMirrored = true,
},
attributeRequirementMinimums = minimums,
}

local previousClosePopup = main.ClosePopup
main.ClosePopup = function() end
local ok, finishError = pcall(queryGen.FinishQuery, queryGen)
main.ClosePopup = previousClosePopup
assert.is_true(ok, finishError)

return queryTable, errMsg
end

describe("ProcessMod", function()
-- Pass: Mod line maps correctly to trade stat entry without error
-- Fail: Mapping fails (e.g., no match found), indicating incomplete stat parsing for curse mods, potentially missing curse-enabling items in queries
Expand Down Expand Up @@ -204,4 +249,94 @@ describe("TradeQueryGenerator", function()
assert.is_not_nil(query.filters.socket_filters.filters.links)
end)
end)

describe("attribute requirement filters", function()
for _, case in ipairs({
{ name = "admits an equipped candidate whose Strength is amplified", customMods = "+50 to Strength\n100% increased Strength", originalStrength = 30, candidateMods = "+26 to Strength", strength = 192, minimum = 26 },
{ name = "does not tighten the filter when a candidate increases reduced Strength", customMods = "+150 to Strength\n10% reduced Strength", originalStrength = 50, candidateMods = "+38 to Strength\n6% increased Strength", strength = 200, minimum = 38 },
}) do
it(case.name, function()
newBuild()
build.characterLevel, build.characterLevelAutoMode = 100, false
build.configTab.input.customMods = case.customMods
build.configTab:BuildModList()
build.itemsTab:AddItem(new("Item"):Item("Rarity: RARE\nRequirement Anchor\nGlorious Plate\nImplicits: 0"))
build.itemsTab:AddItem(new("Item"):Item("Rarity: RARE\nOriginal Ring\nGold Ring\nImplicits: 0\n+" .. case.originalStrength .. " to Strength"))
build.modFlag, build.buildFlag = true, true
runCallback("OnFrame")
local candidate = new("Item"):Item("Rarity: RARE\nCandidate Ring\nGold Ring\nImplicits: 0\n" .. case.candidateMods)
local calc = build.calcsTab:GetMiscCalculator()
local equipped = calc({ repSlotName = "Ring 1", repItem = candidate })
assert.are.equal(case.strength, equipped.Str)
assert.are.equal(191, equipped.ReqStr)
local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = build.itemsTab })
local query
queryGen.requesterCallback = function(_, queryJSON) query = dkjson.decode(queryJSON) end
queryGen:StartQuery(build.itemsTab.slots["Ring 1"], {
statWeights = { { stat = "Life", weightMult = 1 } },
includeAttributeRequirementFilters = true, includeMirrored = true,
})
while queryGen.calcContext.co do queryGen:OnFrame() end
assert.are.equal(case.minimum, findStatFilter(query, "pseudo.pseudo_total_strength", "and").value.min)
end)
end

it("calculates attribute minimums from the blank replacement output", function()
local calcArgs
local modDB = new("ModDB"):ModDB()
for attribute, value in pairs({ Str = 40, Dex = 35, Int = 20 }) do
modDB:NewMod(attribute, "BASE", value, "Fixture")
end
local calcsTab = {
GetMiscCalculator = function()
return function(args)
calcArgs = args
return {
TotalDPS = 100,
ReqStr = 50, Str = 40,
ReqDex = 30, Dex = 35,
ReqInt = 25, Int = 20,
}, modDB
end, { TotalDPS = 100 }
end,
}
local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({
itemsTab = {
items = {
[1] = { baseName = "Gold Ring" },
},
build = { calcsTab = calcsTab },
},
})
local previousOpenPopup = main.OpenPopup
main.OpenPopup = function() return {} end
local ok, startError = pcall(queryGen.StartQuery, queryGen, { slotName = "Ring 1", selItemId = 1 }, {
statWeights = { { stat = "TotalDPS", weightMult = 1 } },
})
main.OpenPopup = previousOpenPopup
assert.is_true(ok, startError)
assert.are.equal("Ring 1", calcArgs.repSlotName)
assert.are.equal("Gold Ring", calcArgs.repItem.baseName)
assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementMinimums)
end)

it("adds needed attribute pseudo filters to the generated query", function()
local queryTable, errMsg = finishQueryWithAttributeMinimums({ Str = 12, Dex = 34, Int = 56 }, true)
assert.is_nil(errMsg)
assert.are.equal(12, findStatFilter(queryTable, "pseudo.pseudo_total_strength").value.min)
assert.are.equal(34, findStatFilter(queryTable, "pseudo.pseudo_total_dexterity").value.min)
assert.are.equal(56, findStatFilter(queryTable, "pseudo.pseudo_total_intelligence").value.min)
end)

it("omits attribute pseudo filters when disabled or no shortfall exists", function()
local disabledQuery = finishQueryWithAttributeMinimums({ Str = 12, Dex = 34, Int = 56 }, false)
local zeroQuery = finishQueryWithAttributeMinimums({ Str = 0, Dex = 0, Int = 0 }, true)
assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_strength"))
assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_dexterity"))
assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_intelligence"))
assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_strength"))
assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_dexterity"))
assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_intelligence"))
end)
end)
end)
128 changes: 127 additions & 1 deletion spec/System/TestTradeQuery_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ describe("TradeQuery", function()
describe("result dropdown tooltipFunc", function()
-- Builds a TradeQuery with the strict minimum needed for
-- PriceItemRowDisplay to construct row 1 without exploding. Only the
-- two itemsTab subtables read by the slot lookup at the top of
-- three itemsTab fields read by the slot lookup at the top of
-- PriceItemRowDisplay need to be created here; everything else either
-- lives behind a callback we never trigger, or is already initialized
-- by the TradeQuery constructor.
local function newTradeQuery(state)
local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} })
tq.itemsTab.activeItemSet = {}
tq.itemsTab.slots = {}
tq.itemsTab.sockets = {}
tq.slotTables[1] = { slotName = "Ring 1" }
if state.resultTbl then tq.resultTbl = state.resultTbl end
if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end
Expand All @@ -30,6 +31,13 @@ describe("TradeQuery", function()
return tq.controls.resultDropdown1
end

it("constructs the Watcher's Eye row without an active jewel socket", function()
local tq = newTradeQuery({})
tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true }

assert.is_not_nil(buildRow1Dropdown(tq))
end)

it("returns early when sortedResultTbl[row_idx] is missing", function()
-- No sorted results at all -> first guard must short-circuit.
local tq = newTradeQuery({})
Expand Down Expand Up @@ -61,6 +69,123 @@ describe("TradeQuery", function()
assert.are.equal(0, #tooltip.lines)
end)
end)
describe("replacement slot resolution", function()
it("resolves Abyssal and selected jewel slots without stored row state", function()
local tq = new("TradeQuery"):TradeQuery({
slots = { ["Body Armour Abyssal Socket 1"] = {} },
sockets = { [123] = {} },
})
tq.slotTables = {
{ slotName = "Abyssal Socket 1", fullName = "Body Armour Abyssal Socket 1" },
{ slotName = "Jewel Socket", selectedJewelNodeId = 123 },
{ slotName = "Watcher's Eye", unique = true },
}

assert.are.equal("Body Armour Abyssal Socket 1", tq:GetReplacementSlotName(1))
assert.are.equal("Jewel 123", tq:GetReplacementSlotName(2))
assert.is_nil(tq:GetReplacementSlotName(3))
end)
end)

describe("attribute requirement result filtering", function()
local function newTradeQueryWithOutput(output, slotTbl)
local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = { ["Ring 1"] = {} }, sockets = {} })
tq.slotTables[1] = slotTbl or { slotName = "Ring 1" }
tq.resultTbl[1] = {
{ item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" },
}
tq.sortModes = {
Weight = "Weight", StatValue = "StatValue", StatValuePrice = "StatValuePrice", Price = "Price",
}
tq.itemSortSelectionList = { tq.sortModes.Weight }
tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } }
tq.tradeQueryGenerator = mock_queryGen
tq.pbLeague = "Test League"
tq.pbCurrencyConversion = { [tq.pbRealm] = { [tq.pbLeague] = { chaos = 1 } } }
tq.itemsTab.build = {
calcsTab = {
GetMiscCalculator = function()
return type(output) == "function" and output or function() return output end, { Life = 100 }
end,
},
}
return tq
end

for _, mode in ipairs({ "Weight", "StatValue", "StatValuePrice", "Price" }) do
it("filters mixed fetched results in " .. mode .. " mode", function()
local tq = newTradeQueryWithOutput(function(calcArgs)
assert.are.equal("Ring 1", calcArgs.repSlotName)
if calcArgs.repItem.name == "Behemoth Hold, Gold Ring" then
return { ReqStr = 50, Str = 40, Life = 400 }
end
assert.are.equal("Survivor Hold, Gold Ring", calcArgs.repItem.name)
return { ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25, Life = 120 }
end)
-- The rejected result arrives first, costs less, and has more Life.
tq.resultTbl[1][2] = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" }
tq.hideResultsFailingAttributeRequirements = true
local sortedItems, err = tq:SortFetchResults(1, tq.sortModes[mode])
assert.is_nil(err)
assert.are.equal(1, #sortedItems)
assert.are.equal(2, sortedItems[1].index)
end)
end

it("clears the visible selection and price when filtering removes every result", function()
local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 })
tq:PriceItemRowDisplay(1, nil, 0, 20)
local dropdown = tq.controls.resultDropdown1
tq.controls.fullPrice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "")
tq.controls.pbNotice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "")
-- Populate the row through the same path first, so stale state can be detected.
tq:UpdateControlsWithItems(1)
assert.is_not_nil(dropdown:GetSelValue())
assert.are.equal(1, tq.itemIndexTbl[1])
assert.are.equal("1 chaos", tq:GetTotalPriceString())
assert.is_true(tq.controls.importButton1:IsEnabled())
assert.matches("chaos", tq.controls.fullPrice.label, 1, true)

tq.hideResultsFailingAttributeRequirements = true
tq:UpdateControlsWithItems(1)
assert.are.equal(0, dropdown:GetDropCount())
assert.are.equal(0, #tq.sortedResultTbl[1])
assert.is_nil(tq.itemIndexTbl[1])
assert.is_nil(tq.totalPrice[1])
assert.are.equal("", tq:GetTotalPriceString())
assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true)
assert.matches("attribute requirements", tq.controls.pbNotice.label, 1, true)
assert.is_falsy(tq.controls.importButton1:IsEnabled())
assert.are.equal("", tq.controls.whisperButton1:GetProperty("label"))
local tooltip = new("Tooltip"):Tooltip()
for _, control in ipairs({ dropdown, tq.controls.importButton1, tq.controls.whisperButton1 }) do
control.tooltipFunc(tooltip, "DROP", 1, nil)
assert.are.equal(0, #tooltip.lines)
end
end)

it("filters fetched results that do not meet Omniscience requirements", function()
local tq = newTradeQueryWithOutput({ ReqOmni = 100, Omni = 80 })
tq.hideResultsFailingAttributeRequirements = true
local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight)
assert.are.equal(0, #sortedItems)
end)

it("keeps fetched results without recalculating by default", function()
local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end)
local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight)
assert.are.equal(1, #sortedItems)
assert.are.equal(1, sortedItems[1].index)
end)

it("does not apply equipment attribute filtering to rows without a replacement slot", function()
local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end, { slotName = "Megalomaniac", unique = true })
tq.hideResultsFailingAttributeRequirements = true
local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight)
assert.are.equal(1, #sortedItems)
assert.are.equal(1, sortedItems[1].index)
end)
end)
describe("GetResultEvaluation", function()
it("uses the first visible ring for a Pearl result without a selected slot", function()
local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} })
Expand Down Expand Up @@ -90,6 +215,7 @@ describe("TradeQuery", function()
slotName = "Megalomaniac", unique = true, alreadyCorrupted = true, selectedJewelNodeId = 12345,
}
local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} })
tq.itemsTab.sockets = { [12345] = {} }
tq.statSortSelectionList = {}
tq.tradeQueryGenerator = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} })
tq.itemsTab.build = {
Expand Down
27 changes: 26 additions & 1 deletion src/Classes/TradeHelpers.lua
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
-- Path of Building
--
-- Module: Compare Trade Helpers
-- Stateless trade mod lookup/matching and item display helper functions
-- Stateless trade matching, attribute requirement, and item display helpers
--
local m_floor = math.floor
local m_max = math.max

-- The stat description data is big and is only needed once a trade lookup actually runs,
-- so it and its precalculated patterns are built lazily
Expand Down Expand Up @@ -66,6 +67,30 @@ end

local M = {}

local attributeOutputKeys = { "Str", "Dex", "Int" }

-- Shared by pre-fetch query constraints and post-fetch candidate validation.
-- Omniscience replaces the individual attribute requirements when it is active.
function M.getAttributeRequirementShortfall(output)
if (output.ReqOmni or 0) > 0 then
return { Omni = m_max(0, output.ReqOmni - (output.Omni or 0)) }
end
local shortfall = {}
for _, attributeKey in ipairs(attributeOutputKeys) do
shortfall[attributeKey] = m_max(0, (output["Req" .. attributeKey] or 0) - (output[attributeKey] or 0))
end
return shortfall
end

function M.meetsAttributeRequirements(output)
for _, missingAmount in pairs(M.getAttributeRequirementShortfall(output)) do
if missingAmount > 0 then
return false
end
end
return true
end

-- Helper: get rarity color code for an item
--- @param item table
function M.getRarityColor(item)
Expand Down
Loading
Loading