Skip to content
Draft
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
88 changes: 87 additions & 1 deletion spec/System/TestTradeQueryGenerator_spec.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
describe("TradeQueryGenerator", function()
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {} })
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {}, GetTradeStatusOption = function() return "online" end })

describe("ProcessMod", function()
-- Pass: Mod line maps correctly to trade stat entry without error
Expand Down Expand Up @@ -37,6 +37,92 @@
end)
end)

describe("Influence query fragments", function()
local IGNORE = mock_queryGen._INFLUENCE_IGNORE_INDEX -- 1
local NONE = mock_queryGen._INFLUENCE_NONE_INDEX -- 2
local ANY = mock_queryGen._INFLUENCE_ANY_INDEX -- 3
local SHAPER = ANY + 1 -- 4
local ELDER = ANY + 2 -- 5
local build = mock_queryGen._buildInfluenceFilters
local HAS_INFLUENCE = mock_queryGen._hasAnyInfluenceModId -- "pseudo.pseudo_has_influence_count"
local HAS_SHAPER = "pseudo.pseudo_has_shaper_influence"

it("Ignore / Ignore produces no filters", function()
local andGroup, topStats, slots = build(IGNORE, IGNORE)
assert.are.equal(#andGroup, 0)
assert.are.equal(#topStats, 0)
assert.are.equal(slots, 0)
end)

it("None / None emits a NOT clause at the top level (no influences)", function()
local andGroup, topStats, slots = build(NONE, NONE)
assert.are.equal(#andGroup, 0)
assert.are.equal(#topStats, 1)
assert.are.same(topStats[1], { type = "not", filters = { { id = HAS_INFLUENCE } } })
assert.are.equal(slots, 1)
end)

it("Any / Ignore caps min=1 (at least one influence)", function()
local andGroup, topStats, slots = build(ANY, IGNORE)
assert.are.equal(#topStats, 0)
assert.are.same(andGroup, { { id = HAS_INFLUENCE, value = { min = 1 } } })
assert.are.equal(slots, 1)
end)

it("Shaper / None caps exactly 1 of that specific", function()
local andGroup, topStats, slots = build(SHAPER, NONE)
assert.are.equal(#topStats, 0)
assert.are.same(andGroup, {
{ id = HAS_INFLUENCE, value = { min = 1, max = 1 } },
{ id = HAS_SHAPER },
})
assert.are.equal(slots, 2)
end)

it("Shaper / Ignore requires the specific without a count cap", function()
local andGroup, topStats, slots = build(SHAPER, IGNORE)
assert.are.equal(#topStats, 0)
assert.are.same(andGroup, { { id = HAS_SHAPER } })
assert.are.equal(slots, 1)
end)

it("Any / Any caps exactly 2 influences", function()
local andGroup, topStats, slots = build(ANY, ANY)
assert.are.equal(#topStats, 0)
assert.are.same(andGroup, { { id = HAS_INFLUENCE, value = { min = 2, max = 2 } } })
assert.are.equal(slots, 1)
end)

it("Shaper / Any caps exactly 2 including Shaper", function()
local andGroup, topStats, slots = build(SHAPER, ANY)
assert.are.equal(#topStats, 0)
assert.are.same(andGroup, {
{ id = HAS_INFLUENCE, value = { min = 2, max = 2 } },
{ id = HAS_SHAPER },
})
assert.are.equal(slots, 2)
end)

it("Shaper / Elder requires both specifics without a count cap", function()
local andGroup, topStats, slots = build(SHAPER, ELDER)
assert.are.equal(#topStats, 0)
assert.are.equal(#andGroup, 2)
assert.are.equal(slots, 2)
end)

-- Same specific on both sides is redundant at the item level and must produce
-- the exact same filter set as <specific> / None (exactly 1 of that type).
-- Without the None-constraint dedup, it would silently fall back to the

Check warning on line 115 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (dedup)
-- <specific> / Ignore form and fail to cap the influence count.
it("Shaper / Shaper produces the same filters as Shaper / None", function()
local dupAnd, dupStats, dupSlots = build(SHAPER, SHAPER)
local pairedAnd, pairedStats, pairedSlots = build(SHAPER, NONE)
assert.are.same(dupAnd, pairedAnd)
assert.are.same(dupStats, pairedStats)
assert.are.equal(dupSlots, pairedSlots)
end)
end)

describe("Filter prioritization", function()
-- Pass: Limits mods to MAX_FILTERS (2 in test), preserving top priorities
-- Fail: Exceeds limit, indicating over-generation of filters, risking API query size errors or rate limits
Expand Down
197 changes: 171 additions & 26 deletions src/Classes/TradeQueryGenerator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,16 @@ local tradeStatCategoryIndices = {
}

local influenceSuffixes = { "_shaper", "_elder", "_adjudicator", "_basilisk", "_crusader", "_eyrie"}
local influenceDropdownNames = { "None" }
local INFLUENCE_IGNORE_INDEX = 1
local INFLUENCE_NONE_INDEX = 2
local INFLUENCE_ANY_INDEX = 3
local influenceDropdownNames = { "Ignore", "None", "Any" }
local hasInfluenceModIds = { }
for i, curInfluenceInfo in ipairs(itemLib.influenceInfo.default) do
influenceDropdownNames[i + 1] = curInfluenceInfo.display
influenceDropdownNames[i + INFLUENCE_ANY_INDEX] = curInfluenceInfo.display
hasInfluenceModIds[i] = "pseudo.pseudo_has_" .. string.lower(curInfluenceInfo.display) .. "_influence"
end
local hasAnyInfluenceModId = "pseudo.pseudo_has_influence_count"

-- slots that allow eldritch mods (non-unique only)
local eldritchModSlots = {
Expand All @@ -106,6 +110,124 @@ local function logToFile(...)
ConPrintf(...)
end

local function isIgnoredSelection(selectionIndex)
return selectionIndex == nil or selectionIndex == INFLUENCE_IGNORE_INDEX
end

local function isSpecificInfluenceSelection(selectionIndex)
return selectionIndex and selectionIndex > INFLUENCE_ANY_INDEX
end

local function isNoInfluenceSelection(selectionIndex)
return selectionIndex == INFLUENCE_NONE_INDEX
end

local function getInfluenceInfoForSelection(selectionIndex)
if not isSpecificInfluenceSelection(selectionIndex) then
return nil
end
return itemLib.influenceInfo.default[selectionIndex - INFLUENCE_ANY_INDEX]
end

-- Influence dropdown semantics:
-- Ignore = no constraint for that slot, None = missing influence slot,
-- Any = present but unspecified influence slot, Specific = named influence slot.
local function resolveInfluenceQueryState(selection1, selection2)
local state = {
exactCount = nil,
minCount = nil,
specificInfluenceModIds = { },
hasNoneConstraint = false,
}
local positiveSelectionCount = 0
local ignoreSelectionCount = 0
local noneSelectionCount = 0
local seenSpecificInfluenceModIds = { }

for _, selectionIndex in ipairs({ selection1 or INFLUENCE_IGNORE_INDEX, selection2 or INFLUENCE_IGNORE_INDEX }) do
if isIgnoredSelection(selectionIndex) then
ignoreSelectionCount = ignoreSelectionCount + 1
elseif isNoInfluenceSelection(selectionIndex) then
noneSelectionCount = noneSelectionCount + 1
elseif isSpecificInfluenceSelection(selectionIndex) then
local influenceModId = hasInfluenceModIds[selectionIndex - INFLUENCE_ANY_INDEX]
if not seenSpecificInfluenceModIds[influenceModId] then
seenSpecificInfluenceModIds[influenceModId] = true
t_insert(state.specificInfluenceModIds, influenceModId)
positiveSelectionCount = positiveSelectionCount + 1
else
-- Same specific on both sides is redundant at the item level; treat the
-- second slot as None so the pair behaves exactly like <specific> / None
-- (exactly 1 of that type, pseudo_has_influence capped at 1).
state.hasNoneConstraint = true
end
else
positiveSelectionCount = positiveSelectionCount + 1
end
end

if noneSelectionCount > 0 then
state.hasNoneConstraint = true
state.exactCount = positiveSelectionCount
elseif ignoreSelectionCount == 2 then
return state
elseif ignoreSelectionCount > 0 then
if positiveSelectionCount > #state.specificInfluenceModIds then
state.minCount = positiveSelectionCount
end
else
state.exactCount = positiveSelectionCount
end

return state
end

-- Returns true when pseudo_has_influence must be added to enforce a count constraint.
local function needsHasInfluenceFilter(influenceState)
if influenceState.exactCount ~= nil then
return influenceState.exactCount == 0
or influenceState.hasNoneConstraint
or #influenceState.specificInfluenceModIds < influenceState.exactCount
end
return influenceState.minCount ~= nil
end

local function countInfluenceFilters(influenceState)
local count = #influenceState.specificInfluenceModIds
if needsHasInfluenceFilter(influenceState) then
count = count + 1
end
return count
end

-- Builds the influence-related trade query fragments from the two dropdown
-- selections. Returns three values so callers can plug the fragments into the
-- final query and size the remaining filter budget in a single call:
-- andGroup: filters to append inside the top-level AND group
-- topStats: filters to append directly to queryTable.query.stats
-- (only non-empty when the pair represents "no influences", which
-- requires a NOT clause rather than a value range)
-- slotCount: total filter slots the influence fragments will consume
local function buildInfluenceFilters(selection1, selection2)
local state = resolveInfluenceQueryState(selection1, selection2)
local andGroup = { }
local topStats = { }
if needsHasInfluenceFilter(state) then
if state.exactCount == 0 then
-- "has 0 influences" cannot be queried with a value range; use NOT instead.
t_insert(topStats, { type = "not", filters = { { id = hasAnyInfluenceModId } } })
elseif state.exactCount ~= nil then
t_insert(andGroup, { id = hasAnyInfluenceModId, value = { min = state.exactCount, max = state.exactCount } })
else
t_insert(andGroup, { id = hasAnyInfluenceModId, value = { min = state.minCount } })
end
end
for _, modId in ipairs(state.specificInfluenceModIds) do
t_insert(andGroup, { id = modId })
end
return andGroup, topStats, countInfluenceFilters(state)
end

local TradeQueryGeneratorClass = newClass("TradeQueryGenerator", function(self, queryTab)
self:InitMods()
self.queryTab = queryTab
Expand Down Expand Up @@ -789,11 +911,13 @@ function TradeQueryGeneratorClass:StartQuery(slot, options)
local testItem = new("Item", itemRawStr)

-- Apply any requests influences
if options.influence1 > 1 then
testItem[itemLib.influenceInfo.default[options.influence1 - 1].key] = true
local influence1 = getInfluenceInfoForSelection(options.influence1)
if influence1 then
testItem[influence1.key] = true
end
if options.influence2 > 1 then
testItem[itemLib.influenceInfo.default[options.influence2 - 1].key] = true
local influence2 = getInfluenceInfoForSelection(options.influence2)
if influence2 then
testItem[influence2.key] = true
end

-- Calculate base output with a blank item
Expand Down Expand Up @@ -850,8 +974,8 @@ function TradeQueryGeneratorClass:ExecuteQuery()
if self.calcContext.options.includeEldritch ~= "None" and
-- skip weights if we need an influenced item as they can produce really
-- bad results due to the filter limit
self.calcContext.options.influence1 == 1 and
self.calcContext.options.influence2 == 1 then
not isSpecificInfluenceSelection(self.calcContext.options.influence1) and
not isSpecificInfluenceSelection(self.calcContext.options.influence2) then
local omitConditional = self.calcContext.options.includeEldritch == "Omit While"
local eaterMods = self.modData["Eater"]
local exarchMods = self.modData["Exarch"]
Expand Down Expand Up @@ -985,8 +1109,8 @@ function TradeQueryGeneratorClass:FinishQuery()
}

local options = self.calcContext.options

local num_extra = 2
local influenceAndGroup, influenceTopStats, influenceSlotCount = buildInfluenceFilters(options.influence1, options.influence2)
local num_extra = influenceSlotCount
if not options.includeMirrored then
num_extra = num_extra + 1
end
Expand Down Expand Up @@ -1019,14 +1143,13 @@ function TradeQueryGeneratorClass:FinishQuery()

local andFilters = { type = "and", filters = { } }
local options = self.calcContext.options
if options.influence1 > 1 then
t_insert(andFilters.filters, { id = hasInfluenceModIds[options.influence1 - 1] })
filters = filters + 1
for _, stat in ipairs(influenceTopStats) do
t_insert(queryTable.query.stats, stat)
end
if options.influence2 > 1 then
t_insert(andFilters.filters, { id = hasInfluenceModIds[options.influence2 - 1] })
filters = filters + 1
for _, filter in ipairs(influenceAndGroup) do
t_insert(andFilters.filters, filter)
end
filters = filters + influenceSlotCount

if #andFilters.filters > 0 then
t_insert(queryTable.query.stats, andFilters)
Expand Down Expand Up @@ -1118,6 +1241,12 @@ function TradeQueryGeneratorClass:FinishQuery()
main:ClosePopup()
end

TradeQueryGeneratorClass._buildInfluenceFilters = buildInfluenceFilters
TradeQueryGeneratorClass._hasAnyInfluenceModId = hasAnyInfluenceModId
TradeQueryGeneratorClass._INFLUENCE_IGNORE_INDEX = INFLUENCE_IGNORE_INDEX
TradeQueryGeneratorClass._INFLUENCE_NONE_INDEX = INFLUENCE_NONE_INDEX
TradeQueryGeneratorClass._INFLUENCE_ANY_INDEX = INFLUENCE_ANY_INDEX

function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callback)
self.requesterCallback = callback
self.requesterContext = context
Expand Down Expand Up @@ -1218,23 +1347,39 @@ Remove: %s will be removed from the search results.]], term, term, term)
controls.jewelTypeLabel = new("LabelControl", {"RIGHT",controls.jewelType,"LEFT"}, {-5, 0, 0, 16}, "Jewel Type:")
updateLastAnchor(controls.jewelType)
elseif slot and not isAbyssalJewelSlot and context.slotTbl.slotName ~= "Watcher's Eye" then
local selFunc = function()
local function refreshInfluenceDependentControls()
-- influenced items can't have eldritch implicits
if controls.copyEldritch and isEldritchModSlot then
local hasInfluence1 = controls.influence1 and controls.influence1:GetSelValue() ~= "None"
local hasInfluence2 = controls.influence2 and controls.influence2:GetSelValue() ~= "None"
local hasInfluence1 = controls.influence1 and not isIgnoredSelection(controls.influence1.selIndex) and not isNoInfluenceSelection(controls.influence1.selIndex)
local hasInfluence2 = controls.influence2 and not isIgnoredSelection(controls.influence2.selIndex) and not isNoInfluenceSelection(controls.influence2.selIndex)
controls.copyEldritch.enabled = not hasInfluence1 and not hasInfluence2
end
end

local influenceTooltipText = table.concat({
"^7Influence filter (both fields combine):",
"Ignore / Ignore: no filter",
"None / None: no influences",
"Any / None: exactly 1 influence",
"<specific> / None: exactly 1, that specific",
"Any / Ignore: at least 1 influence",
"<specific> / Ignore: at least 1, that specific",
"Any / Any: exactly 2 influences",
"<specific> / Any: exactly 2, including that specific",
"<specific A> / <specific B>: exactly 2, both specifics",
}, "\n")

controls.influence1 = new("DropDownControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 100, 18 },
influenceDropdownNames, selFunc)
controls.influence1:SetSel(self.lastInfluence1 or 1)
influenceDropdownNames, refreshInfluenceDependentControls)
controls.influence1:SetSel(self.lastInfluence1 or INFLUENCE_IGNORE_INDEX)
controls.influence1.tooltipText = influenceTooltipText
controls.influence1Label = new("LabelControl", {"RIGHT",controls.influence1,"LEFT"}, {-5, 0, 0, 16}, "^7Influence 1:")

controls.influence2 = new("DropDownControl", { "TOPLEFT", controls.influence1, "BOTTOMLEFT" }, { 0, 5, 100, 18 },
influenceDropdownNames, selFunc)
controls.influence2:SetSel(self.lastInfluence2 or 1)
selFunc()
influenceDropdownNames, refreshInfluenceDependentControls)
controls.influence2:SetSel(self.lastInfluence2 or INFLUENCE_IGNORE_INDEX)
controls.influence2.tooltipText = influenceTooltipText
refreshInfluenceDependentControls()
controls.influence2Label = new("LabelControl", { "RIGHT", controls.influence2, "LEFT" }, { -5, 0, 0, 16 },
"^7Influence 2:")
updateLastAnchor(controls.influence2, 46)
Expand Down Expand Up @@ -1335,12 +1480,12 @@ Remove: %s will be removed from the search results.]], term, term, term)
if controls.influence1 then
self.lastInfluence1, options.influence1 = controls.influence1.selIndex, controls.influence1.selIndex
else
options.influence1 = 1
options.influence1 = INFLUENCE_IGNORE_INDEX
end
if controls.influence2 then
self.lastInfluence2, options.influence2 = controls.influence2.selIndex, controls.influence2.selIndex
else
options.influence2 = 1
options.influence2 = INFLUENCE_IGNORE_INDEX
end
if controls.jewelType then
self.lastJewelType = controls.jewelType.selIndex
Expand Down
Loading