Skip to content

Commit 909608c

Browse files
mcagnionclaude
andcommitted
fix(trade): support Ignore/None/Any influence query states
Port of bugfix/trade-query-influence-none onto current origin/dev. Adapted to upstream PathOfBuildingCommunity#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) <noreply@anthropic.com>
1 parent 4d5f815 commit 909608c

2 files changed

Lines changed: 221 additions & 24 deletions

File tree

spec/System/TestTradeQueryGenerator_spec.lua

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
describe("TradeQueryGenerator", function()
2-
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {} })
2+
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {}, GetTradeStatusOption = function() return "online" end })
33

44
describe("ProcessMod", function()
55
-- Pass: Mod line maps correctly to trade stat entry without error
@@ -37,6 +37,70 @@ describe("TradeQueryGenerator", function()
3737
end)
3838
end)
3939

40+
describe("Influence query state", function()
41+
local IGNORE = mock_queryGen._INFLUENCE_IGNORE_INDEX -- 1
42+
local NONE = mock_queryGen._INFLUENCE_NONE_INDEX -- 2
43+
local ANY = mock_queryGen._INFLUENCE_ANY_INDEX -- 3
44+
local SHAPER = ANY + 1 -- 4
45+
local ELDER = ANY + 2 -- 5
46+
local resolve = mock_queryGen._resolveInfluenceQueryState
47+
local cost = mock_queryGen._getInfluenceFilterCost
48+
local needs = mock_queryGen._needsHasInfluenceFilter
49+
50+
-- None: uses pseudo_has_influence=0 (1 slot instead of 6-slot NOT filter)
51+
it("None uses 1-slot pseudo_has_influence=0", function()
52+
local state = resolve(NONE, IGNORE)
53+
assert.are.equal(state.exactCount, 0)
54+
assert.is_true(state.hasNoneConstraint)
55+
assert.are.equal(cost(state), 1)
56+
assert.is_true(needs(state))
57+
end)
58+
59+
-- Shaper+None: needs pseudo_has_influence=1 to cap at 1 influence (avoids Shaper+Elder matches)
60+
it("Shaper+None uses 2-slot filter (specific + pseudo_has_influence=1)", function()
61+
local state = resolve(SHAPER, NONE)
62+
assert.are.equal(state.exactCount, 1)
63+
assert.is_true(state.hasNoneConstraint)
64+
assert.are.equal(#state.specificInfluenceModIds, 1)
65+
assert.are.equal(cost(state), 2)
66+
assert.is_true(needs(state))
67+
end)
68+
69+
-- Shaper+Elder: 2 named influences, no None → no pseudo_has_influence needed (saves 1 slot)
70+
it("Shaper+Elder uses 2-slot filter (specific mods only, no pseudo_has_influence)", function()
71+
local state = resolve(SHAPER, ELDER)
72+
assert.are.equal(state.exactCount, 2)
73+
assert.is_false(state.hasNoneConstraint)
74+
assert.are.equal(#state.specificInfluenceModIds, 2)
75+
assert.are.equal(cost(state), 2)
76+
assert.is_false(needs(state))
77+
end)
78+
79+
-- Any+Ignore: minCount=1 → pseudo_has_influence min=1 (1 slot)
80+
it("Any uses 1-slot pseudo_has_influence min=1", function()
81+
local state = resolve(ANY, IGNORE)
82+
assert.are.equal(state.minCount, 1)
83+
assert.are.equal(state.exactCount, nil)
84+
assert.are.equal(cost(state), 1)
85+
assert.is_true(needs(state))
86+
end)
87+
88+
-- Any+Shaper: exactCount=2 with one unnamed slot → needs pseudo_has_influence=2
89+
it("Any+Shaper uses 2-slot filter (specific + pseudo_has_influence=2)", function()
90+
local state = resolve(ANY, SHAPER)
91+
assert.are.equal(state.exactCount, 2)
92+
assert.is_false(state.hasNoneConstraint)
93+
assert.are.equal(#state.specificInfluenceModIds, 1)
94+
assert.are.equal(cost(state), 2)
95+
assert.is_true(needs(state))
96+
end)
97+
98+
-- pseudo_has_influence mod ID is correct
99+
it("hasAnyInfluenceModId is pseudo.pseudo_has_influence_count", function()
100+
assert.are.equal(mock_queryGen._hasAnyInfluenceModId, "pseudo.pseudo_has_influence_count")
101+
end)
102+
end)
103+
40104
describe("Filter prioritization", function()
41105
-- Pass: Limits mods to MAX_FILTERS (2 in test), preserving top priorities
42106
-- Fail: Exceeds limit, indicating over-generation of filters, risking API query size errors or rate limits

src/Classes/TradeQueryGenerator.lua

Lines changed: 156 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,16 @@ local tradeStatCategoryIndices = {
8484
}
8585

8686
local influenceSuffixes = { "_shaper", "_elder", "_adjudicator", "_basilisk", "_crusader", "_eyrie"}
87-
local influenceDropdownNames = { "None" }
87+
local INFLUENCE_IGNORE_INDEX = 1
88+
local INFLUENCE_NONE_INDEX = 2
89+
local INFLUENCE_ANY_INDEX = 3
90+
local influenceDropdownNames = { "Ignore", "None", "Any" }
8891
local hasInfluenceModIds = { }
8992
for i, curInfluenceInfo in ipairs(itemLib.influenceInfo.default) do
90-
influenceDropdownNames[i + 1] = curInfluenceInfo.display
93+
influenceDropdownNames[i + INFLUENCE_ANY_INDEX] = curInfluenceInfo.display
9194
hasInfluenceModIds[i] = "pseudo.pseudo_has_" .. string.lower(curInfluenceInfo.display) .. "_influence"
9295
end
96+
local hasAnyInfluenceModId = "pseudo.pseudo_has_influence_count"
9397

9498
-- slots that allow eldritch mods (non-unique only)
9599
local eldritchModSlots = {
@@ -105,6 +109,91 @@ local function logToFile(...)
105109
ConPrintf(...)
106110
end
107111

112+
local function isIgnoredSelection(selectionIndex)
113+
return selectionIndex == nil or selectionIndex == INFLUENCE_IGNORE_INDEX
114+
end
115+
116+
local function isSpecificInfluenceSelection(selectionIndex)
117+
return selectionIndex and selectionIndex > INFLUENCE_ANY_INDEX
118+
end
119+
120+
local function isNoInfluenceSelection(selectionIndex)
121+
return selectionIndex == INFLUENCE_NONE_INDEX
122+
end
123+
124+
local function getInfluenceInfoForSelection(selectionIndex)
125+
if not isSpecificInfluenceSelection(selectionIndex) then
126+
return nil
127+
end
128+
return itemLib.influenceInfo.default[selectionIndex - INFLUENCE_ANY_INDEX]
129+
end
130+
131+
-- Influence dropdown semantics:
132+
-- Ignore = no constraint for that slot, None = missing influence slot,
133+
-- Any = present but unspecified influence slot, Specific = named influence slot.
134+
local function resolveInfluenceQueryState(selection1, selection2)
135+
local state = {
136+
exactCount = nil,
137+
minCount = nil,
138+
specificInfluenceModIds = { },
139+
hasNoneConstraint = false,
140+
}
141+
local positiveSelectionCount = 0
142+
local ignoreSelectionCount = 0
143+
local noneSelectionCount = 0
144+
local seenSpecificInfluenceModIds = { }
145+
146+
for _, selectionIndex in ipairs({ selection1 or INFLUENCE_IGNORE_INDEX, selection2 or INFLUENCE_IGNORE_INDEX }) do
147+
if isIgnoredSelection(selectionIndex) then
148+
ignoreSelectionCount = ignoreSelectionCount + 1
149+
elseif isNoInfluenceSelection(selectionIndex) then
150+
noneSelectionCount = noneSelectionCount + 1
151+
else
152+
positiveSelectionCount = positiveSelectionCount + 1
153+
if isSpecificInfluenceSelection(selectionIndex) then
154+
local influenceModId = hasInfluenceModIds[selectionIndex - INFLUENCE_ANY_INDEX]
155+
if not seenSpecificInfluenceModIds[influenceModId] then
156+
seenSpecificInfluenceModIds[influenceModId] = true
157+
t_insert(state.specificInfluenceModIds, influenceModId)
158+
end
159+
end
160+
end
161+
end
162+
163+
if noneSelectionCount > 0 then
164+
state.hasNoneConstraint = true
165+
state.exactCount = positiveSelectionCount
166+
elseif ignoreSelectionCount == 2 then
167+
return state
168+
elseif ignoreSelectionCount > 0 then
169+
if positiveSelectionCount > #state.specificInfluenceModIds then
170+
state.minCount = positiveSelectionCount
171+
end
172+
else
173+
state.exactCount = positiveSelectionCount
174+
end
175+
176+
return state
177+
end
178+
179+
-- Returns true when pseudo_has_influence must be added to enforce a count constraint.
180+
local function needsHasInfluenceFilter(influenceState)
181+
if influenceState.exactCount ~= nil then
182+
return influenceState.exactCount == 0
183+
or influenceState.hasNoneConstraint
184+
or #influenceState.specificInfluenceModIds < influenceState.exactCount
185+
end
186+
return influenceState.minCount ~= nil
187+
end
188+
189+
local function getInfluenceFilterCost(influenceState)
190+
local cost = #influenceState.specificInfluenceModIds
191+
if needsHasInfluenceFilter(influenceState) then
192+
cost = cost + 1
193+
end
194+
return cost
195+
end
196+
108197
local TradeQueryGeneratorClass = newClass("TradeQueryGenerator", function(self, queryTab)
109198
self:InitMods()
110199
self.queryTab = queryTab
@@ -866,11 +955,13 @@ function TradeQueryGeneratorClass:StartQuery(slot, options)
866955
local testItem = new("Item", itemRawStr)
867956

868957
-- Apply any requests influences
869-
if options.influence1 > 1 then
870-
testItem[itemLib.influenceInfo.default[options.influence1 - 1].key] = true
958+
local influence1 = getInfluenceInfoForSelection(options.influence1)
959+
if influence1 then
960+
testItem[influence1.key] = true
871961
end
872-
if options.influence2 > 1 then
873-
testItem[itemLib.influenceInfo.default[options.influence2 - 1].key] = true
962+
local influence2 = getInfluenceInfoForSelection(options.influence2)
963+
if influence2 then
964+
testItem[influence2.key] = true
874965
end
875966

876967
-- Calculate base output with a blank item
@@ -927,8 +1018,8 @@ function TradeQueryGeneratorClass:ExecuteQuery()
9271018
if self.calcContext.options.includeEldritch ~= "None" and
9281019
-- skip weights if we need an influenced item as they can produce really
9291020
-- bad results due to the filter limit
930-
self.calcContext.options.influence1 == 1 and
931-
self.calcContext.options.influence2 == 1 then
1021+
not isSpecificInfluenceSelection(self.calcContext.options.influence1) and
1022+
not isSpecificInfluenceSelection(self.calcContext.options.influence2) then
9321023
local omitConditional = self.calcContext.options.includeEldritch == "Omit While"
9331024
local eaterMods = self.modData["Eater"]
9341025
local exarchMods = self.modData["Exarch"]
@@ -1062,8 +1153,10 @@ function TradeQueryGeneratorClass:FinishQuery()
10621153
}
10631154

10641155
local options = self.calcContext.options
1156+
local influenceState = resolveInfluenceQueryState(options.influence1, options.influence2)
1157+
local influenceFilterCost = getInfluenceFilterCost(influenceState)
10651158

1066-
local num_extra = 2
1159+
local num_extra = influenceFilterCost
10671160
if not options.includeMirrored then
10681161
num_extra = num_extra + 1
10691162
end
@@ -1096,12 +1189,20 @@ function TradeQueryGeneratorClass:FinishQuery()
10961189

10971190
local andFilters = { type = "and", filters = { } }
10981191
local options = self.calcContext.options
1099-
if options.influence1 > 1 then
1100-
t_insert(andFilters.filters, { id = hasInfluenceModIds[options.influence1 - 1] })
1192+
if needsHasInfluenceFilter(influenceState) then
1193+
if influenceState.exactCount == 0 then
1194+
-- "has 0 influences" cannot be queried with a value range; use NOT instead
1195+
t_insert(queryTable.query.stats, { type = "not", filters = { { id = hasAnyInfluenceModId } } })
1196+
elseif influenceState.exactCount ~= nil then
1197+
t_insert(andFilters.filters, { id = hasAnyInfluenceModId, value = { min = influenceState.exactCount, max = influenceState.exactCount } })
1198+
else
1199+
t_insert(andFilters.filters, { id = hasAnyInfluenceModId, value = { min = influenceState.minCount } })
1200+
end
11011201
filters = filters + 1
11021202
end
1103-
if options.influence2 > 1 then
1104-
t_insert(andFilters.filters, { id = hasInfluenceModIds[options.influence2 - 1] })
1203+
1204+
for _, modId in ipairs(influenceState.specificInfluenceModIds) do
1205+
t_insert(andFilters.filters, { id = modId })
11051206
filters = filters + 1
11061207
end
11071208

@@ -1195,6 +1296,15 @@ function TradeQueryGeneratorClass:FinishQuery()
11951296
main:ClosePopup()
11961297
end
11971298

1299+
-- Test accessors for influence query state logic (not used in production paths)
1300+
TradeQueryGeneratorClass._resolveInfluenceQueryState = resolveInfluenceQueryState
1301+
TradeQueryGeneratorClass._getInfluenceFilterCost = getInfluenceFilterCost
1302+
TradeQueryGeneratorClass._needsHasInfluenceFilter = needsHasInfluenceFilter
1303+
TradeQueryGeneratorClass._hasAnyInfluenceModId = hasAnyInfluenceModId
1304+
TradeQueryGeneratorClass._INFLUENCE_IGNORE_INDEX = INFLUENCE_IGNORE_INDEX
1305+
TradeQueryGeneratorClass._INFLUENCE_NONE_INDEX = INFLUENCE_NONE_INDEX
1306+
TradeQueryGeneratorClass._INFLUENCE_ANY_INDEX = INFLUENCE_ANY_INDEX
1307+
11981308
function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callback)
11991309
self.requesterCallback = callback
12001310
self.requesterContext = context
@@ -1295,23 +1405,46 @@ Remove: %s will be removed from the search results.]], term, term, term)
12951405
controls.jewelTypeLabel = new("LabelControl", {"RIGHT",controls.jewelType,"LEFT"}, {-5, 0, 0, 16}, "Jewel Type:")
12961406
updateLastAnchor(controls.jewelType)
12971407
elseif slot and not isAbyssalJewelSlot and context.slotTbl.slotName ~= "Watcher's Eye" then
1298-
local selFunc = function()
1408+
local function normalizeInfluenceSelections(changedControl)
1409+
local changedDropdown = changedControl == 1 and controls.influence1 or changedControl == 2 and controls.influence2 or nil
1410+
local otherDropdown = changedControl == 1 and controls.influence2 or changedControl == 2 and controls.influence1 or nil
1411+
1412+
if changedDropdown and otherDropdown then
1413+
if isIgnoredSelection(changedDropdown.selIndex) and isNoInfluenceSelection(otherDropdown.selIndex) then
1414+
changedDropdown:SetSel(INFLUENCE_NONE_INDEX)
1415+
return
1416+
elseif isNoInfluenceSelection(changedDropdown.selIndex) and isIgnoredSelection(otherDropdown.selIndex) then
1417+
otherDropdown:SetSel(INFLUENCE_NONE_INDEX)
1418+
return
1419+
elseif isSpecificInfluenceSelection(changedDropdown.selIndex) and changedDropdown.selIndex == otherDropdown.selIndex then
1420+
changedDropdown:SetSel(INFLUENCE_ANY_INDEX)
1421+
return
1422+
end
1423+
end
1424+
1425+
if isIgnoredSelection(controls.influence1.selIndex) and isNoInfluenceSelection(controls.influence2.selIndex) then
1426+
controls.influence1:SetSel(INFLUENCE_NONE_INDEX)
1427+
elseif isNoInfluenceSelection(controls.influence1.selIndex) and isIgnoredSelection(controls.influence2.selIndex) then
1428+
controls.influence2:SetSel(INFLUENCE_NONE_INDEX)
1429+
end
1430+
12991431
-- influenced items can't have eldritch implicits
13001432
if controls.copyEldritch and isEldritchModSlot then
1301-
local hasInfluence1 = controls.influence1 and controls.influence1:GetSelValue() ~= "None"
1302-
local hasInfluence2 = controls.influence2 and controls.influence2:GetSelValue() ~= "None"
1433+
local hasInfluence1 = controls.influence1 and not isIgnoredSelection(controls.influence1.selIndex) and not isNoInfluenceSelection(controls.influence1.selIndex)
1434+
local hasInfluence2 = controls.influence2 and not isIgnoredSelection(controls.influence2.selIndex) and not isNoInfluenceSelection(controls.influence2.selIndex)
13031435
controls.copyEldritch.enabled = not hasInfluence1 and not hasInfluence2
13041436
end
13051437
end
1438+
13061439
controls.influence1 = new("DropDownControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 100, 18 },
1307-
influenceDropdownNames, selFunc)
1308-
controls.influence1:SetSel(self.lastInfluence1 or 1)
1440+
influenceDropdownNames, function() normalizeInfluenceSelections(1) end)
1441+
controls.influence1:SetSel(self.lastInfluence1 or INFLUENCE_IGNORE_INDEX)
13091442
controls.influence1Label = new("LabelControl", {"RIGHT",controls.influence1,"LEFT"}, {-5, 0, 0, 16}, "^7Influence 1:")
13101443

13111444
controls.influence2 = new("DropDownControl", { "TOPLEFT", controls.influence1, "BOTTOMLEFT" }, { 0, 5, 100, 18 },
1312-
influenceDropdownNames, selFunc)
1313-
controls.influence2:SetSel(self.lastInfluence2 or 1)
1314-
selFunc()
1445+
influenceDropdownNames, function() normalizeInfluenceSelections(2) end)
1446+
controls.influence2:SetSel(self.lastInfluence2 or INFLUENCE_IGNORE_INDEX)
1447+
normalizeInfluenceSelections()
13151448
controls.influence2Label = new("LabelControl", { "RIGHT", controls.influence2, "LEFT" }, { -5, 0, 0, 16 },
13161449
"^7Influence 2:")
13171450
updateLastAnchor(controls.influence2, 46)
@@ -1412,12 +1545,12 @@ Remove: %s will be removed from the search results.]], term, term, term)
14121545
if controls.influence1 then
14131546
self.lastInfluence1, options.influence1 = controls.influence1.selIndex, controls.influence1.selIndex
14141547
else
1415-
options.influence1 = 1
1548+
options.influence1 = INFLUENCE_IGNORE_INDEX
14161549
end
14171550
if controls.influence2 then
14181551
self.lastInfluence2, options.influence2 = controls.influence2.selIndex, controls.influence2.selIndex
14191552
else
1420-
options.influence2 = 1
1553+
options.influence2 = INFLUENCE_IGNORE_INDEX
14211554
end
14221555
if controls.jewelType then
14231556
self.lastJewelType = controls.jewelType.selIndex

0 commit comments

Comments
 (0)