From e8f5eb227c22966a007bf2885e3c1ba2bbfc1c4d Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:51:50 +0300 Subject: [PATCH 1/3] Port Add support for select pseudo stats in trader weights (#10085) --- spec/System/TestTradeQueryGenerator_spec.lua | 103 ++++++++++++++++--- src/Classes/TradeQueryGenerator.lua | 65 ++++++++++-- src/Modules/Common.lua | 2 +- 3 files changed, 145 insertions(+), 25 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 97fb12934b..4f41507039 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -104,23 +104,94 @@ describe("TradeQueryGenerator", function() 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 - it("respects MAX_FILTERS", function() - local orig_max = _G.MAX_FILTERS - _G.MAX_FILTERS = 2 - mock_queryGen.modWeights = { { weight = 10, tradeModId = "id1" }, { weight = 5, tradeModId = "id2" } } - table.sort(mock_queryGen.modWeights, function(a, b) - return math.abs(a.weight) > math.abs(b.weight) - end) - local prioritized = {} - for i, entry in ipairs(mock_queryGen.modWeights) do - if #prioritized < _G.MAX_FILTERS then - table.insert(prioritized, entry) - end + it("counts socket constraints against MAX_FILTERS", function() + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) + queryGen.modWeights = {} + for index = 1, 40 do + table.insert(queryGen.modWeights, { + tradeModId = "explicit.stat_" .. index, + weight = 1, + meanStatDiff = 41 - index, + }) end - assert.are.equal(#prioritized, 2) - _G.MAX_FILTERS = orig_max + queryGen.calcContext = { + testItem = new("Item"):Item("Rarity: RARE\nNew Item\nGold Ring\nImplicits: 0"), + baseOutput = {}, + baseStatValue = 0, + itemCategoryQueryStr = "accessory.ring", + special = {}, + options = { + statWeights = {}, + includeMirrored = false, + sockets = 3, + }, + } + queryGen.tradeTypeIndex = 1 + local query + queryGen.requesterCallback = function(_, queryJson) + query = require("dkjson").decode(queryJson).query + end + queryGen:FinishQuery() + + assert.are.equal(31, #query.stats[1].filters) + assert.is_not_nil(query.filters.equipment_filters.filters.rune_sockets) + end) + end) + + describe("Pseudo stat filters", function() + local function runQuery(modWeights) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) + queryGen.modWeights = modWeights + queryGen.calcContext = { + testItem = new("Item"):Item("Rarity: RARE\nNew Item\nGold Ring\nImplicits: 0"), + baseOutput = {}, + baseStatValue = 0, + itemCategoryQueryStr = "accessory.ring", + special = {}, + options = { statWeights = {}, includeMirrored = true }, + } + queryGen.tradeTypeIndex = 1 + local query + queryGen.requesterCallback = function(_, queryJson) + query = require("dkjson").decode(queryJson).query + end + queryGen:FinishQuery() + return query.stats[1].filters + end + + it("maps single resistances and attributes to pseudo stats, keeping the highest weight", function() + local filters = runQuery({ + -- +#% to Fire Resistance, as explicit and as rune + { tradeModId = "explicit.stat_3372524247", weight = 5, meanStatDiff = 2 }, + { tradeModId = "rune.stat_3372524247", weight = 3, meanStatDiff = 1 }, + -- +# to Strength + { tradeModId = "explicit.stat_4080418644", weight = 7, meanStatDiff = 3 }, + }) + + table.sort(filters, function(a, b) return a.id < b.id end) + assert.are.equal(2, #filters) + assert.are.equal("pseudo.pseudo_total_fire_resistance", filters[1].id) + assert.are.equal(5, filters[1].value.weight) + assert.are.equal("pseudo.pseudo_total_strength", filters[2].id) + assert.are.equal(7, filters[2].value.weight) + end) + + it("drops hybrid resistance and attribute stats", function() + local filters = runQuery({ + -- +#% to Fire and Cold Resistances + { tradeModId = "explicit.stat_2915988346", weight = 9, meanStatDiff = 5 }, + -- +#% to all Elemental Resistances + { tradeModId = "explicit.stat_2901986750", weight = 9, meanStatDiff = 4 }, + -- +# to Strength and Dexterity + { tradeModId = "explicit.stat_538848803", weight = 9, meanStatDiff = 3 }, + -- +# to all Attributes + { tradeModId = "explicit.stat_1379411836", weight = 9, meanStatDiff = 2 }, + -- +# to maximum Life, kept + { tradeModId = "explicit.stat_3299347043", weight = 4, meanStatDiff = 1 }, + }) + + assert.are.equal(1, #filters) + assert.are.equal("explicit.stat_3299347043", filters[1].id) end) end) end) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 8504674eeb..886f4395d6 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1016,23 +1016,72 @@ function TradeQueryGeneratorClass:FinishQuery() local effective_max = MAX_FILTERS - num_extra - local prioritizedMods = {} + local pseudoMap = { + ["3372524247"] = "pseudo.pseudo_total_fire_resistance", + ["4220027924"] = "pseudo.pseudo_total_cold_resistance", + ["1671376347"] = "pseudo.pseudo_total_lightning_resistance", + ["2923486259"] = "pseudo.pseudo_total_chaos_resistance", + ["4080418644"] = "pseudo.pseudo_total_strength", + ["3261801346"] = "pseudo.pseudo_total_dexterity", + ["328541901"] = "pseudo.pseudo_total_intelligence", + } + local ignoredStats = { + -- % all resistances + ["2901986750"] = true, + -- all attributes + ["1379411836"] = true, + ["2897413282"] = true, + } + -- block all hybrid resistance stats + local resElements = { "fire", "cold", "lightning", "chaos" } + for _, elem1 in ipairs(resElements) do + for _, elem2 in ipairs(resElements) do + local stats = { string.format("%s_and_%s_damage_resistance_%%", elem1, elem2) } + ignoredStats[tostring(HashStats(stats))] = true + end + end + -- block all hybrid attribute stats + local attributeElements = { "dexterity", "strength", "intelligence" } + for _, elem1 in ipairs(attributeElements) do + for _, elem2 in ipairs(attributeElements) do + local stats = { string.format("base_%s_and_%s", elem1, elem2) } + ignoredStats[tostring(HashStats(stats))] = true + stats = { string.format("additional_%s_and_%s", elem1, elem2) } + ignoredStats[tostring(HashStats(stats))] = true + end + end + local statFilters = {} + local pseudoMods = {} for _, entry in ipairs(self.modWeights) do - if #prioritizedMods < effective_max then - table.insert(prioritizedMods, entry) + local hash = entry.tradeModId:match("stat_(%d+)") + local filterEntry = { id = entry.tradeModId, value = { weight = (entry.invert == true and entry.weight * -1 or entry.weight) } } + -- avoid adding hybrid stats since we get the weight for them from + -- individual stats + if ignoredStats[hash] then + goto weightContinue + elseif pseudoMap[hash] then + local tradeId = pseudoMap[hash] + filterEntry.id = tradeId + -- avoid adding duplicate pseudo filters: update existing + if pseudoMods[tradeId] then + pseudoMods[tradeId].value.weight = math.max(filterEntry.value.weight, pseudoMods[tradeId].value.weight) + else + pseudoMods[tradeId] = filterEntry + table.insert(statFilters, filterEntry) + end else - break + table.insert(statFilters, filterEntry) end - end - self.modWeights = prioritizedMods + ::weightContinue:: + end for k, v in pairs(self.calcContext.special.queryExtra or {}) do queryTable.query[k] = v end - for _, entry in ipairs(self.modWeights) do - t_insert(queryTable.query.stats[1].filters, { id = entry.tradeModId, value = { weight = (entry.invert == true and entry.weight * -1 or entry.weight) } }) + for _, entry in ipairs(statFilters) do + t_insert(queryTable.query.stats[1].filters, entry) filters = filters + 1 if filters == effective_max then break diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index e3e6c35abe..d4f1ce179d 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -1088,7 +1088,7 @@ local GGG_STAT_HASH32_SEED = 0xC58F1A7B -- used for calculating the trade hash from stat hash fields local GGG_TRADE_SEED = 0x02312233 ---@param stats string[] ----@param extraStat string extra stat for time-lost jewels +---@param extraStat string? extra stat for time-lost jewels ---@return integer function HashStats(stats, extraStat) if extraStat then From 0244f3ed600d2763098fe1a4a48fe9a0fc693890 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:24:11 +0300 Subject: [PATCH 2/3] Disable pseudo stat functionality but keep it for parity --- spec/System/TestTradeQueryGenerator_spec.lua | 57 -------------------- src/Classes/TradeQueryGenerator.lua | 18 ++----- 2 files changed, 4 insertions(+), 71 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 4f41507039..d234231191 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -137,61 +137,4 @@ describe("TradeQueryGenerator", function() assert.is_not_nil(query.filters.equipment_filters.filters.rune_sockets) end) end) - - describe("Pseudo stat filters", function() - local function runQuery(modWeights) - local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) - queryGen.modWeights = modWeights - queryGen.calcContext = { - testItem = new("Item"):Item("Rarity: RARE\nNew Item\nGold Ring\nImplicits: 0"), - baseOutput = {}, - baseStatValue = 0, - itemCategoryQueryStr = "accessory.ring", - special = {}, - options = { statWeights = {}, includeMirrored = true }, - } - queryGen.tradeTypeIndex = 1 - local query - queryGen.requesterCallback = function(_, queryJson) - query = require("dkjson").decode(queryJson).query - end - queryGen:FinishQuery() - return query.stats[1].filters - end - - it("maps single resistances and attributes to pseudo stats, keeping the highest weight", function() - local filters = runQuery({ - -- +#% to Fire Resistance, as explicit and as rune - { tradeModId = "explicit.stat_3372524247", weight = 5, meanStatDiff = 2 }, - { tradeModId = "rune.stat_3372524247", weight = 3, meanStatDiff = 1 }, - -- +# to Strength - { tradeModId = "explicit.stat_4080418644", weight = 7, meanStatDiff = 3 }, - }) - - table.sort(filters, function(a, b) return a.id < b.id end) - assert.are.equal(2, #filters) - assert.are.equal("pseudo.pseudo_total_fire_resistance", filters[1].id) - assert.are.equal(5, filters[1].value.weight) - assert.are.equal("pseudo.pseudo_total_strength", filters[2].id) - assert.are.equal(7, filters[2].value.weight) - end) - - it("drops hybrid resistance and attribute stats", function() - local filters = runQuery({ - -- +#% to Fire and Cold Resistances - { tradeModId = "explicit.stat_2915988346", weight = 9, meanStatDiff = 5 }, - -- +#% to all Elemental Resistances - { tradeModId = "explicit.stat_2901986750", weight = 9, meanStatDiff = 4 }, - -- +# to Strength and Dexterity - { tradeModId = "explicit.stat_538848803", weight = 9, meanStatDiff = 3 }, - -- +# to all Attributes - { tradeModId = "explicit.stat_1379411836", weight = 9, meanStatDiff = 2 }, - -- +# to maximum Life, kept - { tradeModId = "explicit.stat_3299347043", weight = 4, meanStatDiff = 1 }, - }) - - assert.are.equal(1, #filters) - assert.are.equal("explicit.stat_3299347043", filters[1].id) - end) - end) end) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 886f4395d6..2882a80339 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1017,23 +1017,13 @@ function TradeQueryGeneratorClass:FinishQuery() local effective_max = MAX_FILTERS - num_extra local pseudoMap = { - ["3372524247"] = "pseudo.pseudo_total_fire_resistance", - ["4220027924"] = "pseudo.pseudo_total_cold_resistance", - ["1671376347"] = "pseudo.pseudo_total_lightning_resistance", - ["2923486259"] = "pseudo.pseudo_total_chaos_resistance", - ["4080418644"] = "pseudo.pseudo_total_strength", - ["3261801346"] = "pseudo.pseudo_total_dexterity", - ["328541901"] = "pseudo.pseudo_total_intelligence", + -- pseudo stats are disabled for PoE2 due to the trade site counting augment mods in them, + -- which would skew results significantly. however, the feature is kept here for PoB1 parity reasons } local ignoredStats = { - -- % all resistances - ["2901986750"] = true, - -- all attributes - ["1379411836"] = true, - ["2897413282"] = true, } -- block all hybrid resistance stats - local resElements = { "fire", "cold", "lightning", "chaos" } + local resElements = {} for _, elem1 in ipairs(resElements) do for _, elem2 in ipairs(resElements) do local stats = { string.format("%s_and_%s_damage_resistance_%%", elem1, elem2) } @@ -1041,7 +1031,7 @@ function TradeQueryGeneratorClass:FinishQuery() end end -- block all hybrid attribute stats - local attributeElements = { "dexterity", "strength", "intelligence" } + local attributeElements = {} for _, elem1 in ipairs(attributeElements) do for _, elem2 in ipairs(attributeElements) do local stats = { string.format("base_%s_and_%s", elem1, elem2) } From 2873610c4cb8547a8556c5377e0ee6aa989cc2cf Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:20:19 +0300 Subject: [PATCH 3/3] Port "Improve stat search with pseudo stats and word order ignore" (#10036) --- spec/System/TestSearchHost_spec.lua | 13 +++++++++++ src/Classes/DropDownControl.lua | 10 ++++---- src/Classes/SearchHost.lua | 36 +++++++++++++++++++++++------ src/Classes/TradeQueryGenerator.lua | 25 ++++++++++++++++++-- 4 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 spec/System/TestSearchHost_spec.lua diff --git a/spec/System/TestSearchHost_spec.lua b/spec/System/TestSearchHost_spec.lua new file mode 100644 index 0000000000..5d328d504f --- /dev/null +++ b/spec/System/TestSearchHost_spec.lua @@ -0,0 +1,13 @@ +describe("SearchHost", function() + it("merges all overlapping ranges when word order is ignored", function() + local searchHost = new("SearchHost"):SearchHost(function() + return { "caster" } + end, nil, true) + + for char in ("caster ast ste"):gmatch(".") do + searchHost:OnSearchChar(char) + end + + assert.same({ { from = 1, to = 6 } }, searchHost.searchInfos[1].ranges) + end) +end) diff --git a/src/Classes/DropDownControl.lua b/src/Classes/DropDownControl.lua index 3991207b1c..77953ff10d 100644 --- a/src/Classes/DropDownControl.lua +++ b/src/Classes/DropDownControl.lua @@ -11,7 +11,7 @@ local m_floor = math.floor ---@class DropDownControl: Control, ControlHost, TooltipHost, SearchHost local DropDownClass = newClass("DropDownControl", "Control", "ControlHost", "TooltipHost", "SearchHost") -function DropDownClass:DropDownControl(anchor, rect, list, selFunc, tooltipText) +function DropDownClass:DropDownControl(anchor, rect, list, selFunc, tooltipText, ignoreSearchOrder) self:Control(anchor, rect) self:ControlHost() self:TooltipHost(tooltipText) @@ -31,7 +31,8 @@ function DropDownClass:DropDownControl(anchor, rect, list, selFunc, tooltipText) end end return StripEscapes(listVal) - end + end, + ignoreSearchOrder ) self.controls.scrollBar = new("ScrollBarControl"):ScrollBarControl({ "TOPRIGHT", self, "TOPRIGHT" }, { -1, 0, 18, 0 }, (self.height - 4) * 4) self.controls.scrollBar.height = function() @@ -115,13 +116,14 @@ function DropDownClass:DrawSearchHighlights(label, searchInfo, x, y, width, heig local endX = 0 local last = 0 SetDrawColor(1, 1, 0, 0.2) + local strippedLabel = StripEscapes(label) for _, range in ipairs(searchInfo.ranges) do if range.from - last - 1 > 0 then - startX = DrawStringWidth(height, "VAR", label:sub(last + 1, range.from - 1)) + x + endX + startX = DrawStringWidth(height, "VAR", strippedLabel:sub(last + 1, range.from - 1)) + x + endX else startX = endX end - endX = DrawStringWidth(height, "VAR", label:sub(range.from, range.to)) + x + startX + endX = DrawStringWidth(height, "VAR", strippedLabel:sub(range.from, range.to)) + x + startX last = range.to DrawImage(nil, startX, y, endX - startX, height) diff --git a/src/Classes/SearchHost.lua b/src/Classes/SearchHost.lua index ceac2b6fe4..76163f73e3 100644 --- a/src/Classes/SearchHost.lua +++ b/src/Classes/SearchHost.lua @@ -7,11 +7,12 @@ ---@class SearchHost local SearchHostClass = newClass("SearchHost") -function SearchHostClass:SearchHost(listAccessor, valueAccessor) +function SearchHostClass:SearchHost(listAccessor, valueAccessor, ignoreOrder) self.searchListAccessor = listAccessor self.valueAccessor = valueAccessor self.searchTerm = "" self.searchInfos = {} + self.ignoreOrder = ignoreOrder or false return self end @@ -38,7 +39,7 @@ local function wordsToCaselessPatterns(words) return patterns end -local function matchWords(searchWords, entry, valueAccessor) +local function matchWords(searchWords, entry, valueAccessor, ignoreOrder) local value = valueAccessor and valueAccessor(entry) or entry local searchInfo = { ranges = {}, matches = true } local lastMatchEnd = 0 @@ -47,16 +48,37 @@ local function matchWords(searchWords, entry, valueAccessor) if (from) then local range = { from = from, to = to } table.insert(searchInfo.ranges, range) - lastMatchEnd = to + if not ignoreOrder then + lastMatchEnd = to + end else -- at least one search word did not match at least once (respecting order) searchInfo.matches = false end end + if ignoreOrder then + -- sort to be in left to right order + table.sort(searchInfo.ranges, function(a, b) + return a.from < b.from + end) + -- merge overlapping ranges + local i = 1 + while searchInfo.ranges[i] do + local this = searchInfo.ranges[i] + local next = searchInfo.ranges[i + 1] + if next and next.from <= this.to then + this.to = math.max(this.to, next.to) + table.remove(searchInfo.ranges, i + 1) + -- Check this range again because another range may overlap it. + else + i = i + 1 + end + end + end return searchInfo end -local function matchTerm(searchTerm, list, valueAccessor) +local function matchTerm(searchTerm, list, valueAccessor, ignoreOrder) if not searchTerm or searchTerm == "" or not list then return {} end @@ -64,7 +86,7 @@ local function matchTerm(searchTerm, list, valueAccessor) local searchInfos = {} local searchPatterns = wordsToCaselessPatterns(splitWords(searchTerm)) for idx, entry in ipairs(list) do - searchInfos[idx] = matchWords(searchPatterns, entry, valueAccessor) + searchInfos[idx] = matchWords(searchPatterns, entry, valueAccessor, ignoreOrder) end return searchInfos end @@ -114,7 +136,7 @@ end function SearchHostClass:UpdateSearch() if self.searchListAccessor then - self.searchInfos = matchTerm(self.searchTerm, self.searchListAccessor(), self.valueAccessor) + self.searchInfos = matchTerm(self.searchTerm, self.searchListAccessor(), self.valueAccessor, self.ignoreOrder) self:UpdateMatchCount() end end @@ -128,4 +150,4 @@ end function SearchHostClass:GetSearchTermPretty() local color = self:IsSearchActive() and self.matchCount > 0 and "^xFFFFFF" or "^xFF0000" return color .. self.searchTerm -end \ No newline at end of file +end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 2882a80339..7e7487ce0a 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -95,6 +95,8 @@ local function getStatEntries(modType) ["Rune"] = "rune", ["HeartOfTheWell"] = "explicit", ["AgainstTheDarkness"] = "explicit", + ["pseudo"] = "pseudo", + ["Enchant"] = "enchant", } if tradeStatCategoryIndices[modType] then for i, cat in ipairs(tradeStats) do @@ -1401,6 +1403,25 @@ Remove: anoints are completely ignored, and removed from items.]] end end end + local pseudoStats = getStatEntries("pseudo") + -- map stats and such which are clearly not relevant here + local ignoredStats = { + "^pseudo.lake", + "^pseudo.pseudo_lake", + "^pseudo.pseudo_logbook", + "^pseudo.pseudo_temple", + "^pseudo.pseudo_map", + "^pseudo.pseudo_ritual", + } + for _, entry in ipairs(pseudoStats or {}) do + for _, ignored in ipairs(ignoredStats) do + if entry.id:find(ignored) then + goto pseudoContinue + end + end + t_insert(mods, { label = s_format("^7%s (Pseudo)", entry.text), tradeId = entry.id }) + ::pseudoContinue:: + end return mods end -- amount of mod selectors: technically we could have 40, but the more we have the fewer @@ -1442,7 +1463,7 @@ Remove: anoints are completely ignored, and removed from items.]] selectedMods[i] = copyTable(val) end setModSelectors(controls) - end) + end, nil, true) dropdown.shown = function() return not not selectedMods[i - 1] or i == 1 end @@ -1474,4 +1495,4 @@ Remove: anoints are completely ignored, and removed from items.]] setModSelectors(controls, getModList()) main:OpenPopup(popupWidth, popupHeight, "Query Options", controls) -end \ No newline at end of file +end