From 5f3bb5d6a5d66036fc1963e6c763f0861ab5998c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 16 Apr 2026 20:25:51 +0200 Subject: [PATCH 1/5] fix(trade): support Ignore/None/Any influence query states Port of bugfix/trade-query-influence-none onto current origin/dev. Adapted to upstream #9691 changes: combined normalizeInfluenceSelections with copyEldritch interaction, Watcher's Eye guard, SetSel pattern, ^7 label prefixes. Updated isSpecificInfluenceSelection check for eldritch weight skip in ExecuteQuery. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/System/TestTradeQueryGenerator_spec.lua | 66 ++++++- src/Classes/TradeQueryGenerator.lua | 179 ++++++++++++++++--- 2 files changed, 221 insertions(+), 24 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index befb96a657..d772af2dae 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,70 @@ describe("TradeQueryGenerator", function() end) end) + describe("Influence query state", 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 resolve = mock_queryGen._resolveInfluenceQueryState + local cost = mock_queryGen._getInfluenceFilterCost + local needs = mock_queryGen._needsHasInfluenceFilter + + -- None: uses pseudo_has_influence=0 (1 slot instead of 6-slot NOT filter) + it("None uses 1-slot pseudo_has_influence=0", function() + local state = resolve(NONE, IGNORE) + assert.are.equal(state.exactCount, 0) + assert.is_true(state.hasNoneConstraint) + assert.are.equal(cost(state), 1) + assert.is_true(needs(state)) + end) + + -- Shaper+None: needs pseudo_has_influence=1 to cap at 1 influence (avoids Shaper+Elder matches) + it("Shaper+None uses 2-slot filter (specific + pseudo_has_influence=1)", function() + local state = resolve(SHAPER, NONE) + assert.are.equal(state.exactCount, 1) + assert.is_true(state.hasNoneConstraint) + assert.are.equal(#state.specificInfluenceModIds, 1) + assert.are.equal(cost(state), 2) + assert.is_true(needs(state)) + end) + + -- Shaper+Elder: 2 named influences, no None → no pseudo_has_influence needed (saves 1 slot) + it("Shaper+Elder uses 2-slot filter (specific mods only, no pseudo_has_influence)", function() + local state = resolve(SHAPER, ELDER) + assert.are.equal(state.exactCount, 2) + assert.is_false(state.hasNoneConstraint) + assert.are.equal(#state.specificInfluenceModIds, 2) + assert.are.equal(cost(state), 2) + assert.is_false(needs(state)) + end) + + -- Any+Ignore: minCount=1 → pseudo_has_influence min=1 (1 slot) + it("Any uses 1-slot pseudo_has_influence min=1", function() + local state = resolve(ANY, IGNORE) + assert.are.equal(state.minCount, 1) + assert.are.equal(state.exactCount, nil) + assert.are.equal(cost(state), 1) + assert.is_true(needs(state)) + end) + + -- Any+Shaper: exactCount=2 with one unnamed slot → needs pseudo_has_influence=2 + it("Any+Shaper uses 2-slot filter (specific + pseudo_has_influence=2)", function() + local state = resolve(ANY, SHAPER) + assert.are.equal(state.exactCount, 2) + assert.is_false(state.hasNoneConstraint) + assert.are.equal(#state.specificInfluenceModIds, 1) + assert.are.equal(cost(state), 2) + assert.is_true(needs(state)) + end) + + -- pseudo_has_influence mod ID is correct + it("hasAnyInfluenceModId is pseudo.pseudo_has_influence_count", function() + assert.are.equal(mock_queryGen._hasAnyInfluenceModId, "pseudo.pseudo_has_influence_count") + 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..bdf52145d9 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,91 @@ 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 + else + positiveSelectionCount = positiveSelectionCount + 1 + if isSpecificInfluenceSelection(selectionIndex) then + local influenceModId = hasInfluenceModIds[selectionIndex - INFLUENCE_ANY_INDEX] + if not seenSpecificInfluenceModIds[influenceModId] then + seenSpecificInfluenceModIds[influenceModId] = true + t_insert(state.specificInfluenceModIds, influenceModId) + end + end + 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 getInfluenceFilterCost(influenceState) + local cost = #influenceState.specificInfluenceModIds + if needsHasInfluenceFilter(influenceState) then + cost = cost + 1 + end + return cost +end + local TradeQueryGeneratorClass = newClass("TradeQueryGenerator", function(self, queryTab) self:InitMods() self.queryTab = queryTab @@ -789,11 +878,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 +941,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 +1076,10 @@ function TradeQueryGeneratorClass:FinishQuery() } local options = self.calcContext.options + local influenceState = resolveInfluenceQueryState(options.influence1, options.influence2) + local influenceFilterCost = getInfluenceFilterCost(influenceState) - local num_extra = 2 + local num_extra = influenceFilterCost if not options.includeMirrored then num_extra = num_extra + 1 end @@ -1019,12 +1112,20 @@ 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] }) + if needsHasInfluenceFilter(influenceState) then + if influenceState.exactCount == 0 then + -- "has 0 influences" cannot be queried with a value range; use NOT instead + t_insert(queryTable.query.stats, { type = "not", filters = { { id = hasAnyInfluenceModId } } }) + elseif influenceState.exactCount ~= nil then + t_insert(andFilters.filters, { id = hasAnyInfluenceModId, value = { min = influenceState.exactCount, max = influenceState.exactCount } }) + else + t_insert(andFilters.filters, { id = hasAnyInfluenceModId, value = { min = influenceState.minCount } }) + end filters = filters + 1 end - if options.influence2 > 1 then - t_insert(andFilters.filters, { id = hasInfluenceModIds[options.influence2 - 1] }) + + for _, modId in ipairs(influenceState.specificInfluenceModIds) do + t_insert(andFilters.filters, { id = modId }) filters = filters + 1 end @@ -1118,6 +1219,15 @@ function TradeQueryGeneratorClass:FinishQuery() main:ClosePopup() end +-- Test accessors for influence query state logic (not used in production paths) +TradeQueryGeneratorClass._resolveInfluenceQueryState = resolveInfluenceQueryState +TradeQueryGeneratorClass._getInfluenceFilterCost = getInfluenceFilterCost +TradeQueryGeneratorClass._needsHasInfluenceFilter = needsHasInfluenceFilter +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 +1328,46 @@ 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 normalizeInfluenceSelections(changedControl) + local changedDropdown = changedControl == 1 and controls.influence1 or changedControl == 2 and controls.influence2 or nil + local otherDropdown = changedControl == 1 and controls.influence2 or changedControl == 2 and controls.influence1 or nil + + if changedDropdown and otherDropdown then + if isIgnoredSelection(changedDropdown.selIndex) and isNoInfluenceSelection(otherDropdown.selIndex) then + changedDropdown:SetSel(INFLUENCE_NONE_INDEX) + return + elseif isNoInfluenceSelection(changedDropdown.selIndex) and isIgnoredSelection(otherDropdown.selIndex) then + otherDropdown:SetSel(INFLUENCE_NONE_INDEX) + return + elseif isSpecificInfluenceSelection(changedDropdown.selIndex) and changedDropdown.selIndex == otherDropdown.selIndex then + changedDropdown:SetSel(INFLUENCE_ANY_INDEX) + return + end + end + + if isIgnoredSelection(controls.influence1.selIndex) and isNoInfluenceSelection(controls.influence2.selIndex) then + controls.influence1:SetSel(INFLUENCE_NONE_INDEX) + elseif isNoInfluenceSelection(controls.influence1.selIndex) and isIgnoredSelection(controls.influence2.selIndex) then + controls.influence2:SetSel(INFLUENCE_NONE_INDEX) + end + -- 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 + controls.influence1 = new("DropDownControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 100, 18 }, - influenceDropdownNames, selFunc) - controls.influence1:SetSel(self.lastInfluence1 or 1) + influenceDropdownNames, function() normalizeInfluenceSelections(1) end) + controls.influence1:SetSel(self.lastInfluence1 or INFLUENCE_IGNORE_INDEX) 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, function() normalizeInfluenceSelections(2) end) + controls.influence2:SetSel(self.lastInfluence2 or INFLUENCE_IGNORE_INDEX) + normalizeInfluenceSelections() controls.influence2Label = new("LabelControl", { "RIGHT", controls.influence2, "LEFT" }, { -5, 0, 0, 16 }, "^7Influence 2:") updateLastAnchor(controls.influence2, 46) @@ -1335,12 +1468,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 From 3f032779c2f45a51dbcd6c0728f5c2f261e310d4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 21 Apr 2026 01:32:35 +0200 Subject: [PATCH 2/5] fix(trade): preserve every valid influence pair in the query UI The influence callback was over-normalizing Ignore+None and None+specific pairs, forcing them back to None/None and locking users out of states the query state resolver already supports (exactCount=1, exactCount=1 plus that specific). Users stuck at None/None could not return to an unfiltered pair. Drop the UI normalization. The callback keeps only the eldritch side-effect it was carrying. All pair combinations now flow through to resolveInfluenceQueryState as intended. Same specific on both sides (Shaper/Shaper) is redundant at the item level, so resolveInfluenceQueryState treats the second slot as None: the pair now generates the same query as Shaper/None (exactly 1 of that type, pseudo_has_influence capped at 1). Without the None constraint the duplicate specific would have silently produced a looser query than the paired form. Test coverage added asserts state equality and query-cost equality with the paired form. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/System/TestTradeQueryGenerator_spec.lua | 21 +++++++++ src/Classes/TradeQueryGenerator.lua | 49 +++++++------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index d772af2dae..19aa12dae5 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -99,6 +99,27 @@ describe("TradeQueryGenerator", function() it("hasAnyInfluenceModId is pseudo.pseudo_has_influence_count", function() assert.are.equal(mock_queryGen._hasAnyInfluenceModId, "pseudo.pseudo_has_influence_count") end) + + -- Same specific influence on both sides is redundant at the item level and must + -- collapse to the / None semantic (exactly 1 of that type), not + -- double-count and not collapse to / Ignore. Verify via the + -- needsHasInfluenceFilter gate that builds the pseudo_has_influence cap — without + -- it, the query would match items with any number of influences including Shaper. + it("Shaper+Shaper produces the full Shaper+None query state", function() + local dup = resolve(SHAPER, SHAPER) + local paired = resolve(SHAPER, NONE) + assert.are.equal(dup.exactCount, paired.exactCount) + assert.are.equal(dup.hasNoneConstraint, paired.hasNoneConstraint) + assert.are.equal(#dup.specificInfluenceModIds, #paired.specificInfluenceModIds) + assert.are.equal(dup.specificInfluenceModIds[1], paired.specificInfluenceModIds[1]) + assert.are.equal(cost(dup), cost(paired)) + assert.are.equal(needs(dup), needs(paired)) + end) + + it("Shaper+Shaper needs the pseudo_has_influence cap to enforce exactly 1", function() + local state = resolve(SHAPER, SHAPER) + assert.is_true(needs(state)) + end) end) describe("Filter prioritization", function() diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index bdf52145d9..743da0495d 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -149,15 +149,20 @@ local function resolveInfluenceQueryState(selection1, selection2) 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 - if isSpecificInfluenceSelection(selectionIndex) then - local influenceModId = hasInfluenceModIds[selectionIndex - INFLUENCE_ANY_INDEX] - if not seenSpecificInfluenceModIds[influenceModId] then - seenSpecificInfluenceModIds[influenceModId] = true - t_insert(state.specificInfluenceModIds, influenceModId) - end - end end end @@ -1328,29 +1333,7 @@ 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 function normalizeInfluenceSelections(changedControl) - local changedDropdown = changedControl == 1 and controls.influence1 or changedControl == 2 and controls.influence2 or nil - local otherDropdown = changedControl == 1 and controls.influence2 or changedControl == 2 and controls.influence1 or nil - - if changedDropdown and otherDropdown then - if isIgnoredSelection(changedDropdown.selIndex) and isNoInfluenceSelection(otherDropdown.selIndex) then - changedDropdown:SetSel(INFLUENCE_NONE_INDEX) - return - elseif isNoInfluenceSelection(changedDropdown.selIndex) and isIgnoredSelection(otherDropdown.selIndex) then - otherDropdown:SetSel(INFLUENCE_NONE_INDEX) - return - elseif isSpecificInfluenceSelection(changedDropdown.selIndex) and changedDropdown.selIndex == otherDropdown.selIndex then - changedDropdown:SetSel(INFLUENCE_ANY_INDEX) - return - end - end - - if isIgnoredSelection(controls.influence1.selIndex) and isNoInfluenceSelection(controls.influence2.selIndex) then - controls.influence1:SetSel(INFLUENCE_NONE_INDEX) - elseif isNoInfluenceSelection(controls.influence1.selIndex) and isIgnoredSelection(controls.influence2.selIndex) then - controls.influence2:SetSel(INFLUENCE_NONE_INDEX) - end - + local function refreshInfluenceDependentControls() -- influenced items can't have eldritch implicits if controls.copyEldritch and isEldritchModSlot then local hasInfluence1 = controls.influence1 and not isIgnoredSelection(controls.influence1.selIndex) and not isNoInfluenceSelection(controls.influence1.selIndex) @@ -1360,14 +1343,14 @@ Remove: %s will be removed from the search results.]], term, term, term) end controls.influence1 = new("DropDownControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 100, 18 }, - influenceDropdownNames, function() normalizeInfluenceSelections(1) end) + influenceDropdownNames, refreshInfluenceDependentControls) controls.influence1:SetSel(self.lastInfluence1 or INFLUENCE_IGNORE_INDEX) 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, function() normalizeInfluenceSelections(2) end) + influenceDropdownNames, refreshInfluenceDependentControls) controls.influence2:SetSel(self.lastInfluence2 or INFLUENCE_IGNORE_INDEX) - normalizeInfluenceSelections() + refreshInfluenceDependentControls() controls.influence2Label = new("LabelControl", { "RIGHT", controls.influence2, "LEFT" }, { -5, 0, 0, 16 }, "^7Influence 2:") updateLastAnchor(controls.influence2, 46) From 8f42814a06b6d3daf8bb9bba6c6fdbf2323bad8e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 21 Apr 2026 01:33:00 +0200 Subject: [PATCH 3/5] feat(trade): document influence-filter combinations via tooltip Add a shared tooltip on both Influence fields listing the nine distinct pair semantics (no filter, no influences, exactly 1, at least 1, exactly 2, plus the specific variants). Ordered by the number of required influences so users can scan to the constraint they want. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Classes/TradeQueryGenerator.lua | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 743da0495d..f425b18524 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1342,14 +1342,29 @@ Remove: %s will be removed from the search results.]], term, term, term) 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, 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, 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:") From 822262b9fdd186541f7afb01fd87b29fe099160a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 21 Apr 2026 01:53:33 +0200 Subject: [PATCH 4/5] refactor(trade): rename influence filter cost to count "Cost" was the only occurrence of the term in this file: the rest of the filter-budget path uses num_extra as the running count of structural filter slots consumed before weighted mods fill the remainder. Align with that convention: getInfluenceFilterCost becomes countInfluenceFilters and the intermediate influenceFilterCost variable is dropped in favour of a direct assignment to num_extra. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/System/TestTradeQueryGenerator_spec.lua | 14 +++++++------- src/Classes/TradeQueryGenerator.lua | 8 +++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 19aa12dae5..88fb6ed4b9 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -44,7 +44,7 @@ describe("TradeQueryGenerator", function() local SHAPER = ANY + 1 -- 4 local ELDER = ANY + 2 -- 5 local resolve = mock_queryGen._resolveInfluenceQueryState - local cost = mock_queryGen._getInfluenceFilterCost + local count = mock_queryGen._countInfluenceFilters local needs = mock_queryGen._needsHasInfluenceFilter -- None: uses pseudo_has_influence=0 (1 slot instead of 6-slot NOT filter) @@ -52,7 +52,7 @@ describe("TradeQueryGenerator", function() local state = resolve(NONE, IGNORE) assert.are.equal(state.exactCount, 0) assert.is_true(state.hasNoneConstraint) - assert.are.equal(cost(state), 1) + assert.are.equal(count(state), 1) assert.is_true(needs(state)) end) @@ -62,7 +62,7 @@ describe("TradeQueryGenerator", function() assert.are.equal(state.exactCount, 1) assert.is_true(state.hasNoneConstraint) assert.are.equal(#state.specificInfluenceModIds, 1) - assert.are.equal(cost(state), 2) + assert.are.equal(count(state), 2) assert.is_true(needs(state)) end) @@ -72,7 +72,7 @@ describe("TradeQueryGenerator", function() assert.are.equal(state.exactCount, 2) assert.is_false(state.hasNoneConstraint) assert.are.equal(#state.specificInfluenceModIds, 2) - assert.are.equal(cost(state), 2) + assert.are.equal(count(state), 2) assert.is_false(needs(state)) end) @@ -81,7 +81,7 @@ describe("TradeQueryGenerator", function() local state = resolve(ANY, IGNORE) assert.are.equal(state.minCount, 1) assert.are.equal(state.exactCount, nil) - assert.are.equal(cost(state), 1) + assert.are.equal(count(state), 1) assert.is_true(needs(state)) end) @@ -91,7 +91,7 @@ describe("TradeQueryGenerator", function() assert.are.equal(state.exactCount, 2) assert.is_false(state.hasNoneConstraint) assert.are.equal(#state.specificInfluenceModIds, 1) - assert.are.equal(cost(state), 2) + assert.are.equal(count(state), 2) assert.is_true(needs(state)) end) @@ -112,7 +112,7 @@ describe("TradeQueryGenerator", function() assert.are.equal(dup.hasNoneConstraint, paired.hasNoneConstraint) assert.are.equal(#dup.specificInfluenceModIds, #paired.specificInfluenceModIds) assert.are.equal(dup.specificInfluenceModIds[1], paired.specificInfluenceModIds[1]) - assert.are.equal(cost(dup), cost(paired)) + assert.are.equal(count(dup), count(paired)) assert.are.equal(needs(dup), needs(paired)) end) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index f425b18524..1577121b3b 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -192,7 +192,7 @@ local function needsHasInfluenceFilter(influenceState) return influenceState.minCount ~= nil end -local function getInfluenceFilterCost(influenceState) +local function countInfluenceFilters(influenceState) local cost = #influenceState.specificInfluenceModIds if needsHasInfluenceFilter(influenceState) then cost = cost + 1 @@ -1082,9 +1082,7 @@ function TradeQueryGeneratorClass:FinishQuery() local options = self.calcContext.options local influenceState = resolveInfluenceQueryState(options.influence1, options.influence2) - local influenceFilterCost = getInfluenceFilterCost(influenceState) - - local num_extra = influenceFilterCost + local num_extra = countInfluenceFilters(influenceState) if not options.includeMirrored then num_extra = num_extra + 1 end @@ -1226,7 +1224,7 @@ end -- Test accessors for influence query state logic (not used in production paths) TradeQueryGeneratorClass._resolveInfluenceQueryState = resolveInfluenceQueryState -TradeQueryGeneratorClass._getInfluenceFilterCost = getInfluenceFilterCost +TradeQueryGeneratorClass._countInfluenceFilters = countInfluenceFilters TradeQueryGeneratorClass._needsHasInfluenceFilter = needsHasInfluenceFilter TradeQueryGeneratorClass._hasAnyInfluenceModId = hasAnyInfluenceModId TradeQueryGeneratorClass._INFLUENCE_IGNORE_INDEX = INFLUENCE_IGNORE_INDEX From 8bd2c7deb83b3e64008011d251c7fdd9cd72b016 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 21 Apr 2026 02:02:59 +0200 Subject: [PATCH 5/5] refactor(trade): expose a single buildInfluenceFilters helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three helpers resolveInfluenceQueryState / needsHasInfluenceFilter / countInfluenceFilters were each exported to the test mock so the spec could assert on intermediate state. That surface tested the shape of the computation rather than its outcome. Introduce buildInfluenceFilters(selection1, selection2) that returns the exact query fragments ExecuteQuery needs — the and-group filters, the top-level NOT stats (only populated for "no influences"), and the total filter-slot count. ExecuteQuery now computes the influence filters and the slot budget in a single call and inlines the fragments, dropping ~20 lines of duplicated query-building logic. Tests are rewritten to exercise the helper directly and assert on the resulting filter table, giving genuine end-to-end coverage of the nine pair combinations. Only _buildInfluenceFilters, _hasAnyInfluenceModId and the INFLUENCE_*_INDEX constants remain as test accessors; the three internal-state helpers are no longer reachable from outside the module. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/System/TestTradeQueryGenerator_spec.lua | 125 ++++++++++--------- src/Classes/TradeQueryGenerator.lua | 62 +++++---- 2 files changed, 102 insertions(+), 85 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 88fb6ed4b9..2a776bda27 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -37,88 +37,89 @@ describe("TradeQueryGenerator", function() end) end) - describe("Influence query state", function() + 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 resolve = mock_queryGen._resolveInfluenceQueryState - local count = mock_queryGen._countInfluenceFilters - local needs = mock_queryGen._needsHasInfluenceFilter + local build = mock_queryGen._buildInfluenceFilters + local HAS_INFLUENCE = mock_queryGen._hasAnyInfluenceModId -- "pseudo.pseudo_has_influence_count" + local HAS_SHAPER = "pseudo.pseudo_has_shaper_influence" - -- None: uses pseudo_has_influence=0 (1 slot instead of 6-slot NOT filter) - it("None uses 1-slot pseudo_has_influence=0", function() - local state = resolve(NONE, IGNORE) - assert.are.equal(state.exactCount, 0) - assert.is_true(state.hasNoneConstraint) - assert.are.equal(count(state), 1) - assert.is_true(needs(state)) + 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) - -- Shaper+None: needs pseudo_has_influence=1 to cap at 1 influence (avoids Shaper+Elder matches) - it("Shaper+None uses 2-slot filter (specific + pseudo_has_influence=1)", function() - local state = resolve(SHAPER, NONE) - assert.are.equal(state.exactCount, 1) - assert.is_true(state.hasNoneConstraint) - assert.are.equal(#state.specificInfluenceModIds, 1) - assert.are.equal(count(state), 2) - assert.is_true(needs(state)) + 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) - -- Shaper+Elder: 2 named influences, no None → no pseudo_has_influence needed (saves 1 slot) - it("Shaper+Elder uses 2-slot filter (specific mods only, no pseudo_has_influence)", function() - local state = resolve(SHAPER, ELDER) - assert.are.equal(state.exactCount, 2) - assert.is_false(state.hasNoneConstraint) - assert.are.equal(#state.specificInfluenceModIds, 2) - assert.are.equal(count(state), 2) - assert.is_false(needs(state)) + 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) - -- Any+Ignore: minCount=1 → pseudo_has_influence min=1 (1 slot) - it("Any uses 1-slot pseudo_has_influence min=1", function() - local state = resolve(ANY, IGNORE) - assert.are.equal(state.minCount, 1) - assert.are.equal(state.exactCount, nil) - assert.are.equal(count(state), 1) - assert.is_true(needs(state)) + 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) - -- Any+Shaper: exactCount=2 with one unnamed slot → needs pseudo_has_influence=2 - it("Any+Shaper uses 2-slot filter (specific + pseudo_has_influence=2)", function() - local state = resolve(ANY, SHAPER) - assert.are.equal(state.exactCount, 2) - assert.is_false(state.hasNoneConstraint) - assert.are.equal(#state.specificInfluenceModIds, 1) - assert.are.equal(count(state), 2) - assert.is_true(needs(state)) + 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) - -- pseudo_has_influence mod ID is correct - it("hasAnyInfluenceModId is pseudo.pseudo_has_influence_count", function() - assert.are.equal(mock_queryGen._hasAnyInfluenceModId, "pseudo.pseudo_has_influence_count") + 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) - -- Same specific influence on both sides is redundant at the item level and must - -- collapse to the / None semantic (exactly 1 of that type), not - -- double-count and not collapse to / Ignore. Verify via the - -- needsHasInfluenceFilter gate that builds the pseudo_has_influence cap — without - -- it, the query would match items with any number of influences including Shaper. - it("Shaper+Shaper produces the full Shaper+None query state", function() - local dup = resolve(SHAPER, SHAPER) - local paired = resolve(SHAPER, NONE) - assert.are.equal(dup.exactCount, paired.exactCount) - assert.are.equal(dup.hasNoneConstraint, paired.hasNoneConstraint) - assert.are.equal(#dup.specificInfluenceModIds, #paired.specificInfluenceModIds) - assert.are.equal(dup.specificInfluenceModIds[1], paired.specificInfluenceModIds[1]) - assert.are.equal(count(dup), count(paired)) - assert.are.equal(needs(dup), needs(paired)) + 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+Shaper needs the pseudo_has_influence cap to enforce exactly 1", function() - local state = resolve(SHAPER, SHAPER) - assert.is_true(needs(state)) + 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) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 1577121b3b..19f305362b 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -193,11 +193,39 @@ local function needsHasInfluenceFilter(influenceState) end local function countInfluenceFilters(influenceState) - local cost = #influenceState.specificInfluenceModIds + local count = #influenceState.specificInfluenceModIds if needsHasInfluenceFilter(influenceState) then - cost = cost + 1 + count = count + 1 end - return cost + 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) @@ -1081,8 +1109,8 @@ function TradeQueryGeneratorClass:FinishQuery() } local options = self.calcContext.options - local influenceState = resolveInfluenceQueryState(options.influence1, options.influence2) - local num_extra = countInfluenceFilters(influenceState) + local influenceAndGroup, influenceTopStats, influenceSlotCount = buildInfluenceFilters(options.influence1, options.influence2) + local num_extra = influenceSlotCount if not options.includeMirrored then num_extra = num_extra + 1 end @@ -1115,22 +1143,13 @@ function TradeQueryGeneratorClass:FinishQuery() local andFilters = { type = "and", filters = { } } local options = self.calcContext.options - if needsHasInfluenceFilter(influenceState) then - if influenceState.exactCount == 0 then - -- "has 0 influences" cannot be queried with a value range; use NOT instead - t_insert(queryTable.query.stats, { type = "not", filters = { { id = hasAnyInfluenceModId } } }) - elseif influenceState.exactCount ~= nil then - t_insert(andFilters.filters, { id = hasAnyInfluenceModId, value = { min = influenceState.exactCount, max = influenceState.exactCount } }) - else - t_insert(andFilters.filters, { id = hasAnyInfluenceModId, value = { min = influenceState.minCount } }) - end - filters = filters + 1 + for _, stat in ipairs(influenceTopStats) do + t_insert(queryTable.query.stats, stat) end - - for _, modId in ipairs(influenceState.specificInfluenceModIds) do - t_insert(andFilters.filters, { id = modId }) - 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) @@ -1222,10 +1241,7 @@ function TradeQueryGeneratorClass:FinishQuery() main:ClosePopup() end --- Test accessors for influence query state logic (not used in production paths) -TradeQueryGeneratorClass._resolveInfluenceQueryState = resolveInfluenceQueryState -TradeQueryGeneratorClass._countInfluenceFilters = countInfluenceFilters -TradeQueryGeneratorClass._needsHasInfluenceFilter = needsHasInfluenceFilter +TradeQueryGeneratorClass._buildInfluenceFilters = buildInfluenceFilters TradeQueryGeneratorClass._hasAnyInfluenceModId = hasAnyInfluenceModId TradeQueryGeneratorClass._INFLUENCE_IGNORE_INDEX = INFLUENCE_IGNORE_INDEX TradeQueryGeneratorClass._INFLUENCE_NONE_INDEX = INFLUENCE_NONE_INDEX