diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index befb96a657..2a776bda27 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -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 @@ -37,6 +37,92 @@ describe("TradeQueryGenerator", function() 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 / None (exactly 1 of that type). + -- Without the None-constraint dedup, it would silently fall back to the + -- / 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 diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index eeb2fdeaab..19f305362b 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -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 = { @@ -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 / 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 @@ -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 @@ -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"] @@ -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 @@ -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) @@ -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 @@ -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", + " / None: exactly 1, that specific", + "Any / Ignore: at least 1 influence", + " / Ignore: at least 1, that specific", + "Any / Any: exactly 2 influences", + " / Any: exactly 2, including that specific", + " / : 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) @@ -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