diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 4890207cea..cd6701f6fe 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -464,4 +464,59 @@ describe("TestItemParse", function() assert.are.equal(1, #item.buffModLines) assert.are.equal("+1500 to Armour", item.buffModLines[1].line) end) + + it("Catalyst quality from PoB internal format", function() + -- Fertile = index 3, Prismatic = index 7 in catalystList + local item = new("Item", raw("Catalyst: Fertile\nCatalystQuality: 20")) + assert.are.equals(3, item.catalyst) + assert.are.equals(20, item.catalystQuality) + + item = new("Item", raw("Catalyst: Prismatic\nCatalystQuality: 12")) + assert.are.equals(7, item.catalyst) + assert.are.equals(12, item.catalystQuality) + end) + + it("Catalyst quality from game clipboard format", function() + -- Game clipboard uses "Quality (X Modifiers)" header rather than "Catalyst:" + -- Life and Mana Modifiers -> Fertile = index 3 + local item = new("Item", raw("Quality (Life and Mana Modifiers): +20% (augmented)")) + assert.are.equals(3, item.catalyst) + assert.are.equals(20, item.catalystQuality) + + -- Resistance Modifiers -> Prismatic = index 7 + item = new("Item", raw("Quality (Resistance Modifiers): +12% (augmented)")) + assert.are.equals(7, item.catalyst) + assert.are.equals(12, item.catalystQuality) + end) + + it("PoB internal catalyst format takes precedence over clipboard format", function() + -- If both are present, the internal Catalyst: line wins (parsed first; clipboard guarded by 'not self.catalyst') + local item = new("Item", raw("Catalyst: Fertile\nCatalystQuality: 20\nQuality (Resistance Modifiers): +12% (augmented)")) + assert.are.equals(3, item.catalyst) -- Fertile, not Prismatic + assert.are.equals(20, item.catalystQuality) + end) + + it("valueScalar annotation parsed from mod line", function() + local item = new("Item", raw("{valueScalar:1.2}+50 to maximum Life")) + assert.are.equals(1.2, item.explicitModLines[1].valueScalar) + end) + + it("valueScalar annotation preserved through BuildRaw round-trip", function() + local item = new("Item", raw("{valueScalar:1.2}+50 to maximum Life")) + assert.are.equals(1.2, item.explicitModLines[1].valueScalar) + -- Serialised raw should contain the annotation + local rawStr = item:BuildRaw() + assert.truthy(rawStr:find("valueScalar:1.2", 1, true)) + -- Re-parsing restores the value + local item2 = new("Item", rawStr) + assert.are.equals(1.2, item2.explicitModLines[1].valueScalar) + end) + + it("valueScalar of exactly 1 is not written to BuildRaw", function() + -- Scalar = 1 is a no-op; writing it would create noise in saved items + local item = new("Item", raw("+50 to maximum Life")) + assert.is_nil(item.explicitModLines[1].valueScalar) + local rawStr = item:BuildRaw() + assert.falsy(rawStr:find("valueScalar", 1, true)) + end) end) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e8e93774ca..6221a7578b 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -57,4 +57,186 @@ describe("TradeQueryGenerator", function() _G.MAX_FILTERS = orig_max end) end) + + describe("Catalyst de-augmentation", function() + -- The formula used in FinishQuery to strip catalyst quality from mod values before + -- setting required minimums: floor(value / ((100 + quality) / 100) + 0.5) + + -- Pass: Correctly reverses a 20% catalyst boost on a round value + -- Fail: Wrong result means required minimums would be too strict (filtered value still includes catalyst bonus) + it("reverses 20% quality boost on round value", function() + -- 60 life boosted by 20% catalyst -> 72; de-augmenting 72 should give 60 + local boosted = math.floor(60 * 1.2) -- = 72 + local deaugmented = math.floor(boosted / ((100 + 20) / 100) + 0.5) + assert.are.equal(60, deaugmented) + end) + + -- Pass: Rounds to nearest integer, avoiding over-filtering on non-round base values + -- Fail: Truncation instead of rounding would produce 59 here, filtering out valid items + it("rounds to nearest integer (not truncates)", function() + -- base = 53, boosted by 12% = floor(53 * 1.12) = 59; de-augmenting 59 should give 53 + local boosted = math.floor(53 * 1.12) -- = 59 + local deaugmented = math.floor(boosted / ((100 + 12) / 100) + 0.5) + assert.are.equal(53, deaugmented) + end) + + -- Pass: 0% quality is a no-op — de-augmented value equals original + -- Fail: Any deviation would indicate a formula error for non-catalysed items + it("leaves value unchanged at 0 quality", function() + local value = 75 + local deaugmented = math.floor(value / ((100 + 0) / 100) + 0.5) + assert.are.equal(75, deaugmented) + end) + + -- Pass: Handles the maximum catalyst quality (20%) without overflow or precision loss + -- Fail: Floating-point precision error would cause off-by-one on values near rounding boundary + it("handles max catalyst quality (20%)", function() + -- base = 100, boosted = 120; de-augment should return 100 + local boosted = math.floor(100 * 1.2) -- = 120 + local deaugmented = math.floor(boosted / ((100 + 20) / 100) + 0.5) + assert.are.equal(100, deaugmented) + end) + end) + + describe("Require current mods", function() + -- Pass: Crafted mods do not appear in requiredModFilters (users re-craft them) + -- Fail: Crafted mods included would over-constrain the query, hiding items the user could craft onto + it("skips crafted mod lines", function() + local crafted = { line = "+50 to maximum Life", crafted = true } + local normal = { line = "+50 to maximum Life", crafted = false } + -- Simulates the 'if not modLine.crafted' guard inside addModLines + local function isCraftedSkipped(modLine) + return modLine.crafted == true + end + assert.is_true(isCraftedSkipped(crafted)) + assert.is_false(isCraftedSkipped(normal)) + end) + end) + + -- ------------------------------------------------------------------------- + -- TDD tests for crafted-slot filter feature (not yet implemented) + -- These tests define the contract for two new methods: + -- CountCraftedAffixes(prefixes, suffixes, affixes) -> {prefix=N, suffix=M} + -- BuildCraftedSlotFilters(prefixCount, suffixCount) -> array of count-type stat groups + -- ------------------------------------------------------------------------- + + describe("CountCraftedAffixes", function() + -- Crafted mods in item.affixes have a 'types' table instead of weightKey/weightVal. + -- Regular mods use weightKey/weightVal and have no 'types' field. + + -- Pass: No crafted mods means both counts are 0 + -- Fail: Any non-zero result means we are incorrectly treating regular mods as crafted, + -- which would add spurious slot-availability filters to the trade query + it("returns zero counts when no crafted mods are present", function() + local prefixes = { { modId = "Strength1" } } + local suffixes = { { modId = "ColdResist1" } } + local affixes = { + Strength1 = { type = "Suffix", weightKey = { "ring" }, weightVal = { 1000 } }, + ColdResist1 = { type = "Suffix", weightKey = { "ring" }, weightVal = { 1000 } }, + } + local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes) + assert.are.equal(0, result.prefix) + assert.are.equal(0, result.suffix) + end) + + -- Pass: 'types' field (not weightKey) marks a crafted prefix; count = 1 + -- Fail: Count stays 0 means crafted mods are not identified, so the slot filter is never emitted + it("counts a crafted prefix correctly", function() + local prefixes = { { modId = "CraftedLife1" } } + local suffixes = {} + local affixes = { + CraftedLife1 = { type = "Prefix", types = { str_armour = true } }, + } + local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes) + assert.are.equal(1, result.prefix) + assert.are.equal(0, result.suffix) + end) + + -- Pass: Crafted suffix identified; prefix count unaffected + -- Fail: suffix count 0 means suffix slot filters are never added for crafted suffixes + it("counts a crafted suffix correctly", function() + local prefixes = {} + local suffixes = { { modId = "CraftedMana1" } } + local affixes = { + CraftedMana1 = { type = "Suffix", types = { str_armour = true } }, + } + local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes) + assert.are.equal(0, result.prefix) + assert.are.equal(1, result.suffix) + end) + + -- Pass: Mixed item with crafted prefix + regular suffix → prefix=1, suffix=0 + -- Fail: Counting regular mod as crafted would emit a spurious suffix slot filter + it("ignores regular mods alongside crafted mods", function() + local prefixes = { { modId = "CraftedLife1" } } + local suffixes = { { modId = "ColdResist1" } } + local affixes = { + CraftedLife1 = { type = "Prefix", types = { str_armour = true } }, + ColdResist1 = { type = "Suffix", weightKey = { "ring" }, weightVal = { 1000 } }, + } + local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes) + assert.are.equal(1, result.prefix) + assert.are.equal(0, result.suffix) + end) + + -- Pass: "None" and missing affix entries are handled without error + -- Fail: nil access crash when modId = "None" or affixes table has no entry + it("handles None and missing affix entries without error", function() + local prefixes = { { modId = "None" }, { modId = "MissingMod" } } + local suffixes = {} + local affixes = {} + local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes) + assert.are.equal(0, result.prefix) + assert.are.equal(0, result.suffix) + end) + end) + + describe("BuildCraftedSlotFilters", function() + -- Each crafted prefix/suffix requires one "count" stat group in the trade query + -- containing BOTH the empty-slot and crafted-slot pseudo stat IDs. + -- This allows matching items that have either an empty slot OR an existing crafted slot. + + -- Pass: No crafted mods → no filters (no slot constraint added to query) + -- Fail: Non-empty result would add unnecessary stat groups, wasting filter slots + it("returns empty table when both counts are zero", function() + local filters = mock_queryGen:BuildCraftedSlotFilters(0, 0) + assert.are.equal(0, #filters) + end) + + -- Pass: One crafted prefix → one count group for prefix slot availability + -- Fail: No filter = buyer might not be able to re-craft; wrong type = API rejects query + it("emits one count-type stat group for one crafted prefix", function() + local filters = mock_queryGen:BuildCraftedSlotFilters(1, 0) + assert.are.equal(1, #filters) + assert.are.equal("count", filters[1].type) + assert.are.equal(1, filters[1].value.min) + -- Group must contain both the empty-prefix pseudo stat and the crafted-prefix pseudo stat + assert.are.equal(2, #filters[1].filters) + end) + + -- Pass: One crafted suffix → one count group for suffix slot availability + -- Fail: Wrong stat IDs (prefix instead of suffix) = search returns wrong items + it("emits one count-type stat group for one crafted suffix", function() + local filters = mock_queryGen:BuildCraftedSlotFilters(0, 1) + assert.are.equal(1, #filters) + assert.are.equal("count", filters[1].type) + assert.are.equal(1, filters[1].value.min) + assert.are.equal(2, #filters[1].filters) + end) + + -- Pass: One crafted prefix + one crafted suffix → two separate count groups + -- Fail: Only one group = suffix or prefix slot not required by search + it("emits two count groups when both prefix and suffix are crafted", function() + local filters = mock_queryGen:BuildCraftedSlotFilters(1, 1) + assert.are.equal(2, #filters) + end) + + -- Pass: Two crafted prefixes → min = 2 in the prefix count group + -- Fail: min = 1 = buyer might only have 1 slot, missing coverage for 2 crafted prefixes + it("sets min to the crafted count (not always 1)", function() + local filters = mock_queryGen:BuildCraftedSlotFilters(2, 0) + assert.are.equal(1, #filters) + assert.are.equal(2, filters[1].value.min) + end) + end) end) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 4597dde52b..68a0402e99 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -425,6 +425,10 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) else specName, specVal = line:match("^(Requires %a+) (.+)$") end + if not specName then + -- Game clipboard catalyst format: "Quality (Life and Mana Modifiers): +20% (augmented)" + specName, specVal = line:match("^(Quality %([^%%)]+%)): (.+)$") + end if specName then if specName == "Unique ID" then self.uniqueID = specVal @@ -576,6 +580,25 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end elseif specName == "CatalystQuality" then self.catalystQuality = specToNumber(specVal) + elseif specName:match("^Quality %((.-)%)$") and not self.catalyst then + -- Game clipboard format: "Quality (Life and Mana Modifiers): +20% (augmented)" + local qualityModifierToCatalyst = { + ["Attack Modifiers"] = 1, + ["Speed Modifiers"] = 2, + ["Life and Mana Modifiers"] = 3, + ["Caster Modifiers"] = 4, + ["Attribute Modifiers"] = 5, + ["Physical and Chaos Modifiers"] = 6, + ["Resistance Modifiers"] = 7, + ["Defense Modifiers"] = 8, + ["Elemental Modifiers"] = 9, + ["Critical Modifiers"] = 10, + } + local catalystId = qualityModifierToCatalyst[specName:match("^Quality %((.-)%)$")] + if catalystId then + self.catalyst = catalystId + self.catalystQuality = tonumber(specVal:match("(%d+)")) + end elseif specName == "Note" then self.note = specVal elseif specName == "Str" or specName == "Strength" or specName == "Dex" or specName == "Dexterity" or @@ -611,6 +634,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end elseif k == "range" then modLine.range = tonumber(val) + elseif k == "valueScalar" then + modLine.valueScalar = tonumber(val) elseif lineFlags[k] then modLine[k] = true end @@ -823,7 +848,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if modList then modLine.modList = modList modLine.extra = extra - modLine.valueScalar = catalystScalar + modLine.valueScalar = modLine.valueScalar or (catalystScalar ~= 1 and catalystScalar or nil) modLine.range = modLine.range or main.defaultItemAffixQuality t_insert(modLines, modLine) if mode == "GAME" then @@ -1107,6 +1132,9 @@ function ItemClass:BuildRaw() if modLine.range and line:match("%(%-?[%d%.]+%-%-?[%d%.]+%)") then line = "{range:" .. round(modLine.range, 3) .. "}" .. line end + if modLine.valueScalar and modLine.valueScalar ~= 1 then + line = "{valueScalar:" .. round(modLine.valueScalar, 6) .. "}" .. line + end if modLine.crafted then line = "{crafted}" .. line end @@ -1288,7 +1316,7 @@ function ItemClass:Craft() return tonumber(num) + tonumber(other) end) else - local modLine = { line = line, order = order } + local modLine = { line = line, order = order, valueScalar = rangeScalar ~= 1 and rangeScalar or nil } for l = 1, #self.explicitModLines + 1 do if not self.explicitModLines[l] or self.explicitModLines[l].order > order then t_insert(self.explicitModLines, l, modLine) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 29772bead4..6998259fa2 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -98,6 +98,51 @@ local eldritchModSlots = { ["Boots"] = true } +-- pseudoMemberLookup maps tradeModId -> pseudoId. +-- Populated by InitMods() from Data/PseudoStats.lua (or rebuilt when that file doesn't exist). +local pseudoMemberLookup = {} + +-- Crafting-slot pseudo stat IDs looked up dynamically from the trade API stats. +-- Populated by InitMods() so we never hardcode IDs that might change. +local craftingSlotStatIds = { + emptyPrefix = nil, + emptySuffix = nil, + craftedPrefix = nil, + craftedSuffix = nil, +} + +-- Normalizes a trade mod text for matching pseudo stats against their explicit/implicit equivalents. +-- Strips +#%, digits, (Local)/(Global), the word "total", and a leading "to ". +local function normTradeText(text) + text = text:lower() + text = text:gsub("[+#%%]", "") + text = text:gsub("%d+%.?%d*", "") + text = text:gsub("%s*%(local%)%s*", " ") + text = text:gsub("%s*%(global%)%s*", " ") + text = text:gsub("%f[%a]total%f[%A]", "") + text = text:gsub("^%s*to%s+", "") + text = text:gsub("%s+", " ") + return (text:match("^%s*(.-)%s*$")) +end + +-- Forward declaration — defined below, after the class method definitions. +local buildAndCachePseudoMapping + +-- Hard-coded fallbacks for pseudo texts that don't normalize to the same form as any explicit/implicit text. +-- Key = normalized pseudo text; value = list of explicit/implicit tradeModIds to map to that pseudo. +local pseudoNormFallbacks = { + -- Pseudo "+#% total Attack Speed" → "attack speed"; explicit "#% increased Attack Speed" → "increased attack speed" + ["attack speed"] = { "explicit.stat_681332047", "implicit.stat_681332047" }, + -- Pseudo "+#% total Cast Speed" → "cast speed"; explicit "#% increased Cast Speed" → "increased cast speed" + ["cast speed"] = { "explicit.stat_2891184298", "implicit.stat_2891184298" }, + -- Pseudo "+#% Global Critical Strike Chance" → "global critical strike chance"; + -- explicit "#% increased Global Critical Strike Chance" → "increased global critical strike chance" + ["global critical strike chance"] = { "explicit.stat_587431675", "implicit.stat_587431675" }, + -- Pseudo "+#% total Critical Strike Chance for Spells" → "critical strike chance for spells"; + -- explicit "#% increased Critical Strike Chance for Spells" → "increased critical strike chance for spells" + ["critical strike chance for spells"] = { "explicit.stat_737908626", "implicit.stat_737908626" }, +} + local MAX_FILTERS = 35 local function logToFile(...) @@ -394,6 +439,27 @@ function TradeQueryGeneratorClass:InitMods() if file then file:close() self.modData = LoadModule(queryModFilePath) + -- Load cached pseudo stat mapping if available; otherwise build it from a fresh API fetch + local psFile = io.open("Data/PseudoStats.lua","r") + if psFile then + psFile:close() + local pseudoData = LoadModule("Data/PseudoStats.lua") + for modId, pseudoId in pairs(pseudoData) do + if modId == "__craftingSlots__" then + -- Restore crafting-slot stat IDs saved by buildAndCachePseudoMapping + for k, v in pairs(pseudoId) do + craftingSlotStatIds[k] = v + end + else + pseudoMemberLookup[modId] = pseudoId + end + end + else + local tradeStats = fetchStats() + tradeStats:gsub("\n", " ") + local tradeQueryStatsParsed = dkjson.decode(tradeStats) + buildAndCachePseudoMapping(tradeQueryStatsParsed) + end return end @@ -581,6 +647,9 @@ function TradeQueryGeneratorClass:InitMods() queryModsFile:write("-- This file is automatically generated, do not edit!\n-- Stat data (c) Grinding Gear Games\n\n") queryModsFile:write("return " .. stringify(self.modData)) queryModsFile:close() + + -- Build and cache pseudo stat mapping (also captures craftingSlotStatIds) + buildAndCachePseudoMapping(tradeQueryStatsParsed) end function TradeQueryGeneratorClass:GenerateModWeights(modsToTest) @@ -874,15 +943,28 @@ function TradeQueryGeneratorClass:ExecuteQuery() self:GeneratePassiveNodeWeights(self.modData.PassiveNode) return end - self:GenerateModWeights(self.modData["Explicit"]) - self:GenerateModWeights(self.modData["Implicit"]) + local tradeMode = self.calcContext.options.tradeMode or 1 + local existingItemIsUnique = self.calcContext.options.existingItemIsUnique + if tradeMode ~= 4 and not existingItemIsUnique then + -- Standard/Upgrade/Exact: generate explicit weights for rare items. + -- Skipped for unique items — their explicit mods are fixed; only implicit/corrupted vary. + self:GenerateModWeights(self.modData["Explicit"]) + if self.calcContext.options.includeImplicits then + self:GenerateModWeights(self.modData["Implicit"]) + end + elseif existingItemIsUnique then + -- Unique item search: only implicit and corrupted mods differ between copies. + if self.calcContext.options.includeImplicits then + self:GenerateModWeights(self.modData["Implicit"]) + end + end if self.calcContext.options.includeCorrupted then self:GenerateModWeights(self.modData["Corrupted"]) end if self.calcContext.options.includeScourge then self:GenerateModWeights(self.modData["Scourge"]) end - if self.calcContext.options.includeEldritch then + if self.calcContext.options.includeEldritch and not (tradeMode == 4 and self.calcContext.options.isUnique) then self:GenerateModWeights(self.modData["Eater"]) self:GenerateModWeights(self.modData["Exarch"]) end @@ -891,6 +973,213 @@ function TradeQueryGeneratorClass:ExecuteQuery() end end +-- Builds pseudoMemberLookup from the PoE trade API stats data and saves it to Data/PseudoStats.lua. +-- Called once by InitMods() when the cache file doesn't exist yet. +buildAndCachePseudoMapping = function(tradeQueryStatsParsed) + -- Build normalized text -> list of tradeModIds for all explicit/implicit entries + local normTextToModIds = {} + for _, modTypeEntry in ipairs(tradeQueryStatsParsed.result) do + local label = modTypeEntry.label + if label == "Explicit" or label == "Implicit" then + for _, entry in ipairs(modTypeEntry.entries) do + local norm = normTradeText(entry.text) + if not normTextToModIds[norm] then + normTextToModIds[norm] = {} + end + t_insert(normTextToModIds[norm], entry.id) + end + end + end + + -- Match each pseudo stat against explicit/implicit members by normalized text. + -- Also capture crafting-slot stat IDs while iterating the Pseudo category. + local pseudoMapping = {} + for _, modTypeEntry in ipairs(tradeQueryStatsParsed.result) do + if modTypeEntry.label == "Pseudo" then + for _, pseudoEntry in ipairs(modTypeEntry.entries) do + local pseudoId = pseudoEntry.id + local norm = normTradeText(pseudoEntry.text) + local memberIds = pseudoNormFallbacks[norm] or normTextToModIds[norm] + if memberIds then + for _, memberId in ipairs(memberIds) do + pseudoMapping[memberId] = pseudoId + end + end + -- Capture crafting-slot stat IDs by matching display text keywords + local t = pseudoEntry.text:lower() + if t:find("empty prefix") then + craftingSlotStatIds.emptyPrefix = pseudoId + elseif t:find("empty suffix") then + craftingSlotStatIds.emptySuffix = pseudoId + elseif t:find("crafted prefix") then + craftingSlotStatIds.craftedPrefix = pseudoId + elseif t:find("crafted suffix") then + craftingSlotStatIds.craftedSuffix = pseudoId + end + end + break + end + end + + -- Store crafting-slot IDs inside the mapping under a sentinel key so the cache + -- can restore them without an extra API call on subsequent launches. + if craftingSlotStatIds.emptyPrefix then + pseudoMapping["__craftingSlots__"] = craftingSlotStatIds + end + + -- Save to cache + local pseudoStatsFile = io.open("Data/PseudoStats.lua", 'w') + if pseudoStatsFile then + pseudoStatsFile:write("-- This file is automatically generated, do not edit!\n-- Stat data (c) Grinding Gear Games\n\n") + pseudoStatsFile:write("return " .. stringify(pseudoMapping)) + pseudoStatsFile:close() + end + + -- Populate module-level lookup (skip the sentinel key) + for modId, pseudoId in pairs(pseudoMapping) do + if modId ~= "__craftingSlots__" then + pseudoMemberLookup[modId] = pseudoId + end + end +end + +-- Consolidates modWeights entries that share a pseudo stat equivalent into a single entry. +-- weight = average of members (1 unit of the pseudo stat is worth the same regardless of source) +-- meanStatDiff = sum of members (captures combined build impact for sort priority) +local function consolidateToPseudo(modWeights) + local pseudoGroupData = {} + local nonPseudo = {} + + for _, entry in ipairs(modWeights) do + local pseudoId = pseudoMemberLookup[entry.tradeModId] + if pseudoId then + local g = pseudoGroupData[pseudoId] + if not g then + g = { totalWeight = 0, totalMeanStatDiff = 0, count = 0 } + pseudoGroupData[pseudoId] = g + end + g.totalWeight = g.totalWeight + entry.weight + g.totalMeanStatDiff = g.totalMeanStatDiff + entry.meanStatDiff + g.count = g.count + 1 + else + t_insert(nonPseudo, entry) + end + end + + local result = {} + for pseudoId, g in pairs(pseudoGroupData) do + t_insert(result, { + tradeModId = pseudoId, + weight = g.totalWeight / g.count, + meanStatDiff = g.totalMeanStatDiff, + invert = false, + }) + end + for _, e in ipairs(nonPseudo) do + t_insert(result, e) + end + + table.sort(result, function(a, b) + if a.meanStatDiff == b.meanStatDiff then + return math.abs(a.weight) > math.abs(b.weight) + end + return a.meanStatDiff > b.meanStatDiff + end) + + return result +end + +-- Returns {prefix=N, suffix=M} counting how many affixes on the item are crafted (bench) mods. +-- Crafted mods are identified by having a 'types' table instead of weightKey/weightVal. +function TradeQueryGeneratorClass:CountCraftedAffixes(prefixes, suffixes, affixes) + local prefixCount, suffixCount = 0, 0 + for _, affix in ipairs(prefixes) do + local modId = affix.modId + if modId and modId ~= "None" and affixes[modId] and affixes[modId].types ~= nil then + prefixCount = prefixCount + 1 + end + end + for _, affix in ipairs(suffixes) do + local modId = affix.modId + if modId and modId ~= "None" and affixes[modId] and affixes[modId].types ~= nil then + suffixCount = suffixCount + 1 + end + end + return { prefix = prefixCount, suffix = suffixCount } +end + +-- Matches crafted modLines against the affix pool(s) to count unique crafted prefixes/suffixes. +-- One crafted affix can produce multiple stat lines; seenModIds prevents double-counting. +-- itemAffixes = originalItem.affixes (item-type-specific pool, e.g. data.itemMods["Boots"]) +-- globalAffixes = data.itemMods.Item (universal pool where bench-crafted mods live) +function TradeQueryGeneratorClass:CountCraftedAffixesFromModLines(modLines, itemAffixes, globalAffixes) + -- Build normalized mod-line text -> {affixType, modId} for every crafted entry in both pools. + -- Bench-crafted mods (the ones we care about) are always in globalAffixes; itemAffixes is + -- searched first so item-specific overrides win, then globalAffixes fills the rest. + local lineToAffix = {} + local function addPool(pool) + if not pool then return end + for modId, mod in pairs(pool) do + if mod.types then -- only crafted mods use types instead of weightKey/weightVal + for _, line in ipairs(mod) do + local key = line:gsub("[#()0-9%-%+%.]", ""):gsub("%s+", " "):match("^%s*(.-)%s*$") + if key ~= "" and not lineToAffix[key] then + lineToAffix[key] = { affixType = mod.type, modId = modId } + end + end + end + end + end + addPool(itemAffixes) + addPool(globalAffixes) + -- Count unique crafted affixes; one affix may produce several modLines + local seenModIds = {} + local prefixCount, suffixCount = 0, 0 + for _, modLine in ipairs(modLines) do + if modLine.crafted then + local key = modLine.line:gsub("[#()0-9%-%+%.]", ""):gsub("%s+", " "):match("^%s*(.-)%s*$") + local match = lineToAffix[key] + if match and not seenModIds[match.modId] then + seenModIds[match.modId] = true + if match.affixType == "Prefix" then + prefixCount = prefixCount + 1 + elseif match.affixType == "Suffix" then + suffixCount = suffixCount + 1 + end + end + end + end + return { prefix = prefixCount, suffix = suffixCount } +end + +-- Builds count-type stat groups requiring the target item to have available crafting slots. +-- Each crafted prefix/suffix on the current item requires the buyer to have an empty OR crafted slot. +-- Stat IDs are resolved dynamically from the trade API (craftingSlotStatIds) to avoid hardcoding. +function TradeQueryGeneratorClass:BuildCraftedSlotFilters(prefixCount, suffixCount) + local result = {} + if prefixCount > 0 and craftingSlotStatIds.emptyPrefix and craftingSlotStatIds.craftedPrefix then + t_insert(result, { + type = "count", + value = { min = prefixCount }, + filters = { + { id = craftingSlotStatIds.emptyPrefix, value = { min = 1 } }, + { id = craftingSlotStatIds.craftedPrefix, value = { min = 1 } }, + } + }) + end + if suffixCount > 0 and craftingSlotStatIds.emptySuffix and craftingSlotStatIds.craftedSuffix then + t_insert(result, { + type = "count", + value = { min = suffixCount }, + filters = { + { id = craftingSlotStatIds.emptySuffix, value = { min = 1 } }, + { id = craftingSlotStatIds.craftedSuffix, value = { min = 1 } }, + } + }) + end + return result +end + function TradeQueryGeneratorClass:FinishQuery() -- Calc original item Stats without anoint or enchant, and use that diff as a basis for default min sum. local originalItem = self.calcContext.slot and self.itemsTab.items[self.calcContext.slot.selItemId] @@ -921,25 +1210,57 @@ function TradeQueryGeneratorClass:FinishQuery() end return a.meanStatDiff > b.meanStatDiff end) - + + -- Consolidate related explicit/implicit mods into pseudo stats to free up filter slots + if self.calcContext.options.includePseudo then + self.modWeights = consolidateToPseudo(self.modWeights) + end + + local options = self.calcContext.options + local existingItemIsUnique = options.existingItemIsUnique + -- A megalomaniac is not being compared to anything and the currentStatDiff will be 0, so just go for an arbitrary min weight - in this case triple the weight of the worst evaluated node. local megalomaniacSpecialMinWeight = self.calcContext.special.itemName == "Megalomaniac" and self.modWeights[#self.modWeights] * 3 -- This Stat diff value will generally be higher than the weighted sum of the same item, because the stats are all applied at once and can thus multiply off each other. -- So apply a modifier to get a reasonable min and hopefully approximate that the query will start out with small upgrades. local minWeight = megalomaniacSpecialMinWeight or currentStatDiff * 0.5 - + + -- For unique items we only weight implicit/corrupted mods (the ones that vary between copies). + -- The currentStatDiff includes the full item (fixed explicits too), so scale minWeight down + -- proportionally so that having just one good implicit/corrupted mod is enough to match. + if existingItemIsUnique then + local implicitCount = options.existingImplicitCount or 0 + local totalCount = options.existingTotalModCount or 0 + local implicitRatio = (totalCount > 0) and (implicitCount / totalCount) or 0.2 + minWeight = currentStatDiff * 0.5 * implicitRatio + end + -- Generate trade query str and open in browser local filters = 0 + -- For unique items: search by name and restrict to unique rarity instead of non-unique. + local typeFilters + if existingItemIsUnique and options.existingItemTitle then + typeFilters = { + type_filters = { + filters = { + category = { option = self.calcContext.itemCategoryQueryStr }, + rarity = { option = "unique" } + } + } + } + else + typeFilters = self.calcContext.special.queryFilters or { + type_filters = { + filters = { + category = { option = self.calcContext.itemCategoryQueryStr }, + rarity = { option = "nonunique" } + } + } + } + end local queryTable = { query = { - filters = self.calcContext.special.queryFilters or { - type_filters = { - filters = { - category = { option = self.calcContext.itemCategoryQueryStr }, - rarity = { option = "nonunique" } - } - } - }, + filters = typeFilters, status = { option = "available" }, stats = { { @@ -953,8 +1274,6 @@ function TradeQueryGeneratorClass:FinishQuery() engine = "new" } - local options = self.calcContext.options - local num_extra = 2 if not options.includeMirrored then num_extra = num_extra + 1 @@ -971,6 +1290,161 @@ function TradeQueryGeneratorClass:FinishQuery() local effective_max = MAX_FILTERS - num_extra + -- Derive mode flags from the selected trade mode (1=Standard, 2=Upgrade, 3=Exact, 4=Implicit Upgrade) + local tradeMode = options.tradeMode or 1 + -- For unique items the explicit mods are fixed on every copy — no point requiring them. + -- The implicit/corrupted mods (the interesting variation) go into the weight group instead. + local requireCurrentMods = tradeMode >= 2 and not (tradeMode == 4 and options.isUnique) and not existingItemIsUnique + local includeWeights = (tradeMode == 1 or tradeMode == 2) or existingItemIsUnique + local includeImplicitWeights = tradeMode == 4 + + -- Collect required mod filters from the current item's mods if requested + local requiredModFilters = {} + local craftedSlotFilters = {} + if requireCurrentMods and originalItem then + -- Build separate normalized text -> tradeModId lookups so explicit and implicit + -- stat IDs are never confused with each other. + -- Also indexes overrideModLineSingular so "Has 1 Abyssal Socket" matches + -- the entry whose plural text "Has # Abyssal Sockets" would otherwise not. + -- preferLocal: when true, overrideModLine entries (local weapon/armour stats) take + -- priority over same-text global entries so e.g. weapon "Adds # to # Physical Damage" + -- maps to the (Local) trade stat rather than the global ring/jewel version. + local function buildTextLookup(modTypeData, preferLocal) + local lookup = {} + local localOverrides = {} + for _, entry in pairs(modTypeData) do + local key = entry.tradeMod.text:gsub("[#()0-9%-%+%.]",""):gsub("%s+", " ") + if key ~= "" and not lookup[key] then + lookup[key] = entry.tradeMod.id + end + -- overrideModLine is set for "(Local)" trade stats; its value is the item + -- display text without " (Local)", which is what modLine.line will contain. + local overrideLine = entry.specialCaseData and entry.specialCaseData.overrideModLine + if overrideLine then + local overrideKey = overrideLine:gsub("[#()0-9%-%+%.]",""):gsub("%s+", " ") + if overrideKey ~= "" then + localOverrides[overrideKey] = entry.tradeMod.id + end + end + local singular = entry.specialCaseData and entry.specialCaseData.overrideModLineSingular + if singular then + local singKey = singular:gsub("[#()0-9%-%+%.]",""):gsub("%s+", " ") + if singKey ~= "" and not lookup[singKey] then + lookup[singKey] = entry.tradeMod.id + end + end + end + -- Merge local overrides last so they win over any global entry with the same text. + -- Only do this for item types that actually carry local mods (weapons and armour). + if preferLocal then + for k, v in pairs(localOverrides) do + lookup[k] = v + end + end + return lookup + end + local preferLocal = originalItem.base and (originalItem.base.weapon or originalItem.base.armour) and true or false + local explicitTextToId = buildTextLookup(self.modData.Explicit, preferLocal) + local implicitTextToId = buildTextLookup(self.modData.Implicit) + if options.includeEldritch then + for k, v in pairs(buildTextLookup(self.modData.Eater)) do + explicitTextToId[k] = explicitTextToId[k] or v + end + for k, v in pairs(buildTextLookup(self.modData.Exarch)) do + explicitTextToId[k] = explicitTextToId[k] or v + end + end + + -- Build a tradeModId -> modTags lookup so we can check catalyst affectedness + -- from the trade stat ID alone, independent of line text or prefix metadata. + local catalystTagGroups = { + { "attack" }, { "speed" }, { "life", "mana", "resource" }, { "caster" }, + { "jewellery_attribute", "attribute" }, { "physical_damage", "chaos_damage" }, + { "jewellery_resistance", "resistance" }, { "jewellery_defense", "defences" }, + { "jewellery_elemental", "elemental_damage" }, { "critical" }, + } + local tradeModIdToTags = {} + if originalItem.affixes then + for _, mod in pairs(originalItem.affixes) do + if mod.modTags and #mod.modTags > 0 and mod.statOrder and mod.group then + for _, statOrder in ipairs(mod.statOrder) do + local key = tostring(statOrder) .. "_" .. mod.group + for _, modType in ipairs({ "Explicit", "Implicit" }) do + local entry = self.modData[modType] and self.modData[modType][key] + if entry then + tradeModIdToTags[entry.tradeMod.id] = mod.modTags + end + end + end + end + end + end + + -- For implicit mod lines, fall back to the explicit lookup — some mods that + -- appear as implicits on items (e.g. Abyssal Socket) are only registered as + -- explicit stats in modData. In that case, rewrite the "explicit." prefix to + -- "implicit." so the trade filter targets the correct stat category. + local function addModLines(modLines, primaryLookup, fallbackLookup) + for _, modLine in ipairs(modLines) do + if not modLine.crafted then + local matchStr = modLine.line:gsub("[#()0-9%-%+%.]",""):gsub("%s+", " ") + local tradeModId = primaryLookup[matchStr] + local usedFallback = false + if not tradeModId and fallbackLookup then + tradeModId = fallbackLookup[matchStr] + usedFallback = tradeModId ~= nil + end + if tradeModId then + if usedFallback then + tradeModId = tradeModId:gsub("^explicit%.", "implicit.") + end + local value = tonumber(modLine.line:match("(%d+%.?%d*)")) + -- Deaugment catalyst-boosted values using tradeModId -> modTags lookup. + -- This works regardless of prefix metadata or stored line text. + if value and originalItem.catalyst and originalItem.catalystQuality then + local modTags = tradeModIdToTags[tradeModId] + if modTags then + local affectedTags = catalystTagGroups[originalItem.catalyst] + if affectedTags then + local deaugmented = false + for _, modTag in ipairs(modTags) do + if deaugmented then break end + for _, catTag in ipairs(affectedTags) do + if modTag == catTag then + value = math.floor(value / ((100 + originalItem.catalystQuality) / 100) + 0.5) + deaugmented = true + break + end + end + end + end + end + end + t_insert(requiredModFilters, { id = tradeModId, value = value and { min = value } or nil }) + end + end + end + end + addModLines(originalItem.explicitModLines, explicitTextToId, nil) + if tradeMode ~= 4 and options.includeImplicits then + addModLines(originalItem.implicitModLines, implicitTextToId, explicitTextToId) + end + -- Crafted mods are always in explicitModLines with modLine.crafted = true. + -- We need both the item-specific pool (originalItem.affixes) and the universal bench-craft + -- pool (data.itemMods.Item) because bench-crafted mods live in the latter, not the former. + local craftedCounts = self:CountCraftedAffixesFromModLines( + originalItem.explicitModLines, + originalItem.affixes, + data.masterMods) + craftedSlotFilters = self:BuildCraftedSlotFilters(craftedCounts.prefix, craftedCounts.suffix) + effective_max = math.max(0, effective_max - #requiredModFilters - #craftedSlotFilters) + end + + -- Mode 3 (Exact): no weight search at all; Mode 4 keeps only the implicit weights already generated + if not includeWeights and not includeImplicitWeights then + self.modWeights = {} + end + local prioritizedMods = {} for _, entry in ipairs(self.modWeights) do if #prioritizedMods < effective_max then @@ -986,8 +1460,15 @@ function TradeQueryGeneratorClass:FinishQuery() queryTable.query[k] = v end + -- For unique items inject the name so the trade site filters to that specific unique. + if existingItemIsUnique and options.existingItemTitle then + queryTable.query.name = options.existingItemTitle + if options.existingItemBase then + queryTable.query.type = options.existingItemBase + end + end + 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 @@ -997,9 +1478,18 @@ function TradeQueryGeneratorClass:FinishQuery() filters = filters + 1 end + for _, reqFilter in ipairs(requiredModFilters) do + t_insert(andFilters.filters, reqFilter) + filters = filters + 1 + end + if #andFilters.filters > 0 then t_insert(queryTable.query.stats, andFilters) end + for _, slotFilter in ipairs(craftedSlotFilters) do + t_insert(queryTable.query.stats, slotFilter) + filters = filters + 1 + 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) } }) @@ -1008,12 +1498,22 @@ function TradeQueryGeneratorClass:FinishQuery() break end end + -- Remove the weight group if it ended up empty (e.g. Exact mode produces no weight filters) + if #queryTable.query.stats[1].filters == 0 then + table.remove(queryTable.query.stats, 1) + end + -- Build misc_filters for mirrored and/or corrupted exclusions. + local miscFilters = {} if not options.includeMirrored then + miscFilters.mirrored = false + end + if not options.includeCorrupted then + miscFilters.corrupted = false + end + if next(miscFilters) then queryTable.query.filters.misc_filters = { disabled = false, - filters = { - mirrored = false, - } + filters = miscFilters, } end @@ -1071,7 +1571,7 @@ function TradeQueryGeneratorClass:FinishQuery() end local errMsg = nil - if #queryTable.query.stats[1].filters == 0 then + if #queryTable.query.stats == 0 then -- No mods to filter errMsg = "Could not generate search, found no mods to search for" end @@ -1096,15 +1596,16 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb local isAmuletSlot = slot and slot.slotName == "Amulet" local isEldritchModSlot = slot and eldritchModSlots[slot.slotName] == true - controls.includeCorrupted = new("CheckBoxControl", {"TOP",nil,"TOP"}, {-40, 30, 18}, "Corrupted Mods:", function(state) end) - controls.includeCorrupted.state = not context.slotTbl.alreadyCorrupted and (self.lastIncludeCorrupted == nil or self.lastIncludeCorrupted == true) - controls.includeCorrupted.enabled = not context.slotTbl.alreadyCorrupted + -- All controls are left-aligned in a single TOPLEFT chain. + -- The checkbox box sits at x≈176 (popup-relative); labels are drawn to its left. + controls.includeMirrored = new("CheckBoxControl", {"TOP",nil,"TOP"}, {-15, 30, 18}, "Mirrored items:", function(state) end) + controls.includeMirrored.state = (self.lastIncludeMirrored == nil or self.lastIncludeMirrored == true) -- removing checkbox until synthesis mods are supported - --controls.includeSynthesis = new("CheckBoxControl", {"TOPRIGHT",controls.includeEldritch,"BOTTOMRIGHT"}, {0, 5, 18}, "Synthesis Mods:", function(state) end) + --controls.includeSynthesis = new("CheckBoxControl", {"TOPLEFT",controls.includeEldritch,"BOTTOMLEFT"}, {0, 5, 18}, "Synthesis Mods:", function(state) end) --controls.includeSynthesis.state = (self.lastIncludeSynthesis == nil or self.lastIncludeSynthesis == true) - local lastItemAnchor = controls.includeCorrupted + local lastItemAnchor = controls.includeMirrored local includeScourge = self.queryTab.pbLeagueRealName == "Standard" or self.queryTab.pbLeagueRealName == "Hardcore" local function updateLastAnchor(anchor, height) @@ -1116,28 +1617,67 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb options.special = { itemName = context.slotTbl.slotName } end - controls.includeMirrored = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Mirrored items:", function(state) end) - controls.includeMirrored.state = (self.lastIncludeMirrored == nil or self.lastIncludeMirrored == true) updateLastAnchor(controls.includeMirrored) + -- Corrupted Items: directly below Mirrored, before Mode. + -- Unchecked = exclude corrupted (adds corrupted=false to misc_filters, same pattern as mirrored). + -- Checked = allow corrupted (Any) + include corrupted implicit weights. + controls.includeCorrupted = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Corrupted Items:", function(state) end) + controls.includeCorrupted.state = not context.slotTbl.alreadyCorrupted and (self.lastIncludeCorrupted == nil or self.lastIncludeCorrupted == true) + controls.includeCorrupted.enabled = not context.slotTbl.alreadyCorrupted + updateLastAnchor(controls.includeCorrupted) + + local existingItemForSlot = slot and self.itemsTab.items[slot.selItemId] + if existingItemForSlot then + -- Mode dropdown: left-aligned below Mirrored + controls.tradeMode = new("DropDownControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 140, 18}, {"Standard", "Upgrade", "Exact", "Implicit Upgrade"}, function(index, value) + if context.slotTbl.unique and (index == 2 or index == 3) then + controls.tradeMode.tooltipText = "^1This mode is intended for rare items only." + else + controls.tradeMode.tooltipText = nil + end + end) + controls.tradeMode.selIndex = self.lastTradeMode or 1 + -- Initialise tooltip for the persisted selection + if context.slotTbl.unique then + local sel = self.lastTradeMode or 1 + if sel == 2 or sel == 3 then + controls.tradeMode.tooltipText = "^1This mode is intended for rare items only." + end + end + controls.tradeModeLabel = new("LabelControl", {"RIGHT",controls.tradeMode,"LEFT"}, {-5, 0, 0, 16}, "Mode:") + updateLastAnchor(controls.tradeMode) + + -- Pseudo Mods checkbox: below Mode, left-aligned + controls.includePseudo = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Pseudo Mods:", function(state) end) + controls.includePseudo.state = (self.lastIncludePseudo == true) + controls.includePseudo.tooltipText = "Consolidate related explicit/implicit mods into pseudo stat filters" + updateLastAnchor(controls.includePseudo) + + -- Implicit Mods checkbox: below Pseudo Mods + controls.includeImplicits = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Implicit Mods:", function(state) end) + controls.includeImplicits.state = (self.lastIncludeImplicits == nil or self.lastIncludeImplicits == true) + updateLastAnchor(controls.includeImplicits) + end + + if isEldritchModSlot then + controls.includeEldritch = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Eldritch Mods:", function(state) end) + controls.includeEldritch.state = (self.lastIncludeEldritch == true) + updateLastAnchor(controls.includeEldritch) + end + if not isJewelSlot and not isAbyssalJewelSlot and includeScourge then - controls.includeScourge = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Scourge Mods:", function(state) end) + controls.includeScourge = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Scourge Mods:", function(state) end) controls.includeScourge.state = (self.lastIncludeScourge == nil or self.lastIncludeScourge == true) updateLastAnchor(controls.includeScourge) end if isAmuletSlot then - controls.includeTalisman = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Talisman Mods:", function(state) end) + controls.includeTalisman = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Talisman Mods:", function(state) end) controls.includeTalisman.state = (self.lastIncludeTalisman == nil or self.lastIncludeTalisman == true) updateLastAnchor(controls.includeTalisman) end - if isEldritchModSlot then - controls.includeEldritch = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Eldritch Mods:", function(state) end) - controls.includeEldritch.state = (self.lastIncludeEldritch == true) - updateLastAnchor(controls.includeEldritch) - end - if isJewelSlot then controls.jewelType = new("DropDownControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 100, 18}, { "Any", "Base", "Abyss" }, function(index, value) end) controls.jewelType.selIndex = self.lastJewelType or 1 @@ -1219,6 +1759,18 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb if controls.includeMirrored then self.lastIncludeMirrored, options.includeMirrored = controls.includeMirrored.state, controls.includeMirrored.state end + if controls.tradeMode then + self.lastTradeMode = controls.tradeMode.selIndex + options.tradeMode = controls.tradeMode.selIndex + end + options.isUnique = context.slotTbl and context.slotTbl.unique == true + -- Detect when the equipped item is a unique — drives name-based search with implicit/corrupted weight group + local existingItem = slot and self.itemsTab.items[slot.selItemId] + options.existingItemIsUnique = existingItem and existingItem.rarity == "UNIQUE" and not options.isUnique + options.existingItemTitle = options.existingItemIsUnique and existingItem.title or nil + options.existingItemBase = options.existingItemIsUnique and existingItem.baseName or nil + options.existingImplicitCount = options.existingItemIsUnique and (#existingItem.implicitModLines + (existingItem.corrupted and 1 or 0)) or 0 + options.existingTotalModCount = options.existingItemIsUnique and (#existingItem.implicitModLines + #existingItem.explicitModLines + (existingItem.corrupted and 1 or 0)) or 0 if controls.includeCorrupted then self.lastIncludeCorrupted, options.includeCorrupted = controls.includeCorrupted.state, controls.includeCorrupted.state end @@ -1228,6 +1780,16 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb if controls.includeEldritch then self.lastIncludeEldritch, options.includeEldritch = controls.includeEldritch.state, controls.includeEldritch.state end + if controls.includeImplicits then + self.lastIncludeImplicits, options.includeImplicits = controls.includeImplicits.state, controls.includeImplicits.state + else + options.includeImplicits = true + end + if controls.includePseudo then + self.lastIncludePseudo, options.includePseudo = controls.includePseudo.state, controls.includePseudo.state + else + options.includePseudo = true + end if controls.includeScourge then self.lastIncludeScourge, options.includeScourge = controls.includeScourge.state, controls.includeScourge.state end diff --git a/src/Data/PseudoStats.lua b/src/Data/PseudoStats.lua new file mode 100644 index 0000000000..dc3c5a7a51 --- /dev/null +++ b/src/Data/PseudoStats.lua @@ -0,0 +1,163 @@ +-- This file is automatically generated, do not edit! +-- Stat data (c) Grinding Gear Games + +return { + ["__craftingSlots__"] = { + ["craftedPrefix"] = "pseudo.pseudo_number_of_crafted_prefix_mods", + ["craftedSuffix"] = "pseudo.pseudo_number_of_crafted_suffix_mods", + ["emptyPrefix"] = "pseudo.pseudo_number_of_empty_prefix_mods", + ["emptySuffix"] = "pseudo.pseudo_number_of_empty_suffix_mods", + }, + ["explicit.stat_1037193709"] = "pseudo.pseudo_adds_cold_damage", + ["explicit.stat_1050105434"] = "pseudo.pseudo_total_mana", + ["explicit.stat_1133016593"] = "pseudo.pseudo_adds_fire_damage_to_spells", + ["explicit.stat_1170386874"] = "pseudo.pseudo_total_additional_vaal_gem_levels", + ["explicit.stat_1175385867"] = "pseudo.pseudo_increased_burning_damage", + ["explicit.stat_1334060246"] = "pseudo.pseudo_adds_lightning_damage", + ["explicit.stat_1379411836"] = "pseudo.pseudo_total_all_attributes", + ["explicit.stat_1509134228"] = "pseudo.pseudo_increased_physical_damage", + ["explicit.stat_1573130764"] = "pseudo.pseudo_adds_fire_damage_to_attacks", + ["explicit.stat_1645459191"] = "pseudo.pseudo_total_additional_cold_gem_levels", + ["explicit.stat_1671376347"] = "pseudo.pseudo_total_lightning_resistance", + ["explicit.stat_1672793731"] = "pseudo.pseudo_total_additional_warcry_gem_levels", + ["explicit.stat_1719423857"] = "pseudo.pseudo_total_additional_intelligence_gem_levels", + ["explicit.stat_1754445556"] = "pseudo.pseudo_adds_lightning_damage_to_attacks", + ["explicit.stat_1940865751"] = "pseudo.pseudo_adds_physical_damage", + ["explicit.stat_2027269580"] = "pseudo.pseudo_total_additional_bow_gem_levels", + ["explicit.stat_2176571093"] = "pseudo.pseudo_total_additional_projectile_gem_levels", + ["explicit.stat_2223678961"] = "pseudo.pseudo_adds_chaos_damage", + ["explicit.stat_2231156303"] = "pseudo.pseudo_increased_lightning_damage", + ["explicit.stat_2250533757"] = "pseudo.pseudo_increased_movement_speed", + ["explicit.stat_2300399854"] = "pseudo.pseudo_adds_chaos_damage_to_spells", + ["explicit.stat_2387423236"] = "pseudo.pseudo_adds_cold_damage", + ["explicit.stat_2435536961"] = "pseudo.pseudo_adds_physical_damage_to_spells", + ["explicit.stat_2452998583"] = "pseudo.pseudo_total_additional_aura_gem_levels", + ["explicit.stat_2469416729"] = "pseudo.pseudo_adds_cold_damage_to_spells", + ["explicit.stat_2482852589"] = "pseudo.pseudo_increased_energy_shield", + ["explicit.stat_2675603254"] = "pseudo.pseudo_total_additional_chaos_gem_levels", + ["explicit.stat_2718698372"] = "pseudo.pseudo_total_additional_dexterity_gem_levels", + ["explicit.stat_2831165374"] = "pseudo.pseudo_adds_lightning_damage_to_spells", + ["explicit.stat_2843100721"] = "pseudo.pseudo_total_additional_gem_levels", + ["explicit.stat_2891184298"] = "pseudo.pseudo_total_cast_speed", + ["explicit.stat_2901986750"] = "pseudo.pseudo_total_all_elemental_resistances", + ["explicit.stat_2923486259"] = "pseudo.pseudo_total_chaos_resistance", + ["explicit.stat_2974417149"] = "pseudo.pseudo_increased_spell_damage", + ["explicit.stat_3032590688"] = "pseudo.pseudo_adds_physical_damage_to_attacks", + ["explicit.stat_3141070085"] = "pseudo.pseudo_increased_elemental_damage", + ["explicit.stat_321077055"] = "pseudo.pseudo_adds_fire_damage", + ["explicit.stat_3237948413"] = "pseudo.pseudo_physical_attack_damage_leeched_as_mana", + ["explicit.stat_3261801346"] = "pseudo.pseudo_total_dexterity", + ["explicit.stat_328541901"] = "pseudo.pseudo_total_intelligence", + ["explicit.stat_3291658075"] = "pseudo.pseudo_increased_cold_damage", + ["explicit.stat_3299347043"] = "pseudo.pseudo_total_life", + ["explicit.stat_3336890334"] = "pseudo.pseudo_adds_lightning_damage", + ["explicit.stat_3372524247"] = "pseudo.pseudo_total_fire_resistance", + ["explicit.stat_339179093"] = "pseudo.pseudo_total_additional_fire_gem_levels", + ["explicit.stat_3448743676"] = "pseudo.pseudo_total_additional_golem_gem_levels", + ["explicit.stat_3489782002"] = "pseudo.pseudo_total_energy_shield", + ["explicit.stat_3531280422"] = "pseudo.pseudo_adds_chaos_damage", + ["explicit.stat_3556824919"] = "pseudo.pseudo_global_critical_strike_multiplier", + ["explicit.stat_3571342795"] = "pseudo.pseudo_total_additional_elemental_gem_levels", + ["explicit.stat_3593843976"] = "pseudo.pseudo_physical_attack_damage_leeched_as_life", + ["explicit.stat_3604946673"] = "pseudo.pseudo_total_additional_minion_gem_levels", + ["explicit.stat_3691695237"] = "pseudo.pseudo_total_additional_curse_gem_levels", + ["explicit.stat_3852526385"] = "pseudo.pseudo_total_additional_movement_gem_levels", + ["explicit.stat_387439868"] = "pseudo.pseudo_increased_elemental_damage_with_attack_skills", + ["explicit.stat_3917489142"] = "pseudo.pseudo_increased_rarity", + ["explicit.stat_3962278098"] = "pseudo.pseudo_increased_fire_damage", + ["explicit.stat_4043416969"] = "pseudo.pseudo_total_additional_lightning_gem_levels", + ["explicit.stat_4052037485"] = "pseudo.pseudo_total_energy_shield", + ["explicit.stat_4067062424"] = "pseudo.pseudo_adds_cold_damage_to_attacks", + ["explicit.stat_4080418644"] = "pseudo.pseudo_total_strength", + ["explicit.stat_4154259475"] = "pseudo.pseudo_total_additional_support_gem_levels", + ["explicit.stat_4208907162"] = "pseudo.pseudo_increased_lightning_damage_with_attack_skills", + ["explicit.stat_4220027924"] = "pseudo.pseudo_total_cold_resistance", + ["explicit.stat_446733281"] = "pseudo.pseudo_total_additional_spell_gem_levels", + ["explicit.stat_524797741"] = "pseudo.pseudo_total_additional_skill_gem_levels", + ["explicit.stat_55876295"] = "pseudo.pseudo_physical_attack_damage_leeched_as_life", + ["explicit.stat_587431675"] = "pseudo.pseudo_global_critical_strike_chance", + ["explicit.stat_669069897"] = "pseudo.pseudo_physical_attack_damage_leeched_as_mana", + ["explicit.stat_674553446"] = "pseudo.pseudo_adds_chaos_damage_to_attacks", + ["explicit.stat_681332047"] = "pseudo.pseudo_total_attack_speed", + ["explicit.stat_709508406"] = "pseudo.pseudo_adds_fire_damage", + ["explicit.stat_737908626"] = "pseudo.pseudo_critical_strike_chance_for_spells", + ["explicit.stat_789117908"] = "pseudo.pseudo_increased_mana_regen", + ["explicit.stat_829382474"] = "pseudo.pseudo_total_additional_melee_gem_levels", + ["explicit.stat_860668586"] = "pseudo.pseudo_increased_cold_damage_with_attack_skills", + ["explicit.stat_916797432"] = "pseudo.pseudo_total_additional_strength_gem_levels", + ["explicit.stat_960081730"] = "pseudo.pseudo_adds_physical_damage", + ["implicit.stat_1037193709"] = "pseudo.pseudo_adds_cold_damage", + ["implicit.stat_1050105434"] = "pseudo.pseudo_total_mana", + ["implicit.stat_1133016593"] = "pseudo.pseudo_adds_fire_damage_to_spells", + ["implicit.stat_1170386874"] = "pseudo.pseudo_total_additional_vaal_gem_levels", + ["implicit.stat_1175385867"] = "pseudo.pseudo_increased_burning_damage", + ["implicit.stat_1334060246"] = "pseudo.pseudo_adds_lightning_damage", + ["implicit.stat_1379411836"] = "pseudo.pseudo_total_all_attributes", + ["implicit.stat_1509134228"] = "pseudo.pseudo_increased_physical_damage", + ["implicit.stat_1573130764"] = "pseudo.pseudo_adds_fire_damage_to_attacks", + ["implicit.stat_1645459191"] = "pseudo.pseudo_total_additional_cold_gem_levels", + ["implicit.stat_1671376347"] = "pseudo.pseudo_total_lightning_resistance", + ["implicit.stat_1672793731"] = "pseudo.pseudo_total_additional_warcry_gem_levels", + ["implicit.stat_1719423857"] = "pseudo.pseudo_total_additional_intelligence_gem_levels", + ["implicit.stat_1754445556"] = "pseudo.pseudo_adds_lightning_damage_to_attacks", + ["implicit.stat_1940865751"] = "pseudo.pseudo_adds_physical_damage", + ["implicit.stat_2027269580"] = "pseudo.pseudo_total_additional_bow_gem_levels", + ["implicit.stat_2176571093"] = "pseudo.pseudo_total_additional_projectile_gem_levels", + ["implicit.stat_2223678961"] = "pseudo.pseudo_adds_chaos_damage", + ["implicit.stat_2231156303"] = "pseudo.pseudo_increased_lightning_damage", + ["implicit.stat_2250533757"] = "pseudo.pseudo_increased_movement_speed", + ["implicit.stat_2300399854"] = "pseudo.pseudo_adds_chaos_damage_to_spells", + ["implicit.stat_2387423236"] = "pseudo.pseudo_adds_cold_damage", + ["implicit.stat_2435536961"] = "pseudo.pseudo_adds_physical_damage_to_spells", + ["implicit.stat_2452998583"] = "pseudo.pseudo_total_additional_aura_gem_levels", + ["implicit.stat_2468413380"] = "pseudo.pseudo_increased_fire_damage_with_attack_skills", + ["implicit.stat_2469416729"] = "pseudo.pseudo_adds_cold_damage_to_spells", + ["implicit.stat_2482852589"] = "pseudo.pseudo_increased_energy_shield", + ["implicit.stat_2675603254"] = "pseudo.pseudo_total_additional_chaos_gem_levels", + ["implicit.stat_2718698372"] = "pseudo.pseudo_total_additional_dexterity_gem_levels", + ["implicit.stat_2831165374"] = "pseudo.pseudo_adds_lightning_damage_to_spells", + ["implicit.stat_2843100721"] = "pseudo.pseudo_total_additional_gem_levels", + ["implicit.stat_2891184298"] = "pseudo.pseudo_total_cast_speed", + ["implicit.stat_2901986750"] = "pseudo.pseudo_total_all_elemental_resistances", + ["implicit.stat_2923486259"] = "pseudo.pseudo_total_chaos_resistance", + ["implicit.stat_2974417149"] = "pseudo.pseudo_increased_spell_damage", + ["implicit.stat_3032590688"] = "pseudo.pseudo_adds_physical_damage_to_attacks", + ["implicit.stat_3141070085"] = "pseudo.pseudo_increased_elemental_damage", + ["implicit.stat_321077055"] = "pseudo.pseudo_adds_fire_damage", + ["implicit.stat_3237948413"] = "pseudo.pseudo_physical_attack_damage_leeched_as_mana", + ["implicit.stat_3261801346"] = "pseudo.pseudo_total_dexterity", + ["implicit.stat_328541901"] = "pseudo.pseudo_total_intelligence", + ["implicit.stat_3291658075"] = "pseudo.pseudo_increased_cold_damage", + ["implicit.stat_3299347043"] = "pseudo.pseudo_total_life", + ["implicit.stat_3336890334"] = "pseudo.pseudo_adds_lightning_damage", + ["implicit.stat_3372524247"] = "pseudo.pseudo_total_fire_resistance", + ["implicit.stat_339179093"] = "pseudo.pseudo_total_additional_fire_gem_levels", + ["implicit.stat_3489782002"] = "pseudo.pseudo_total_energy_shield", + ["implicit.stat_3531280422"] = "pseudo.pseudo_adds_chaos_damage", + ["implicit.stat_3556824919"] = "pseudo.pseudo_global_critical_strike_multiplier", + ["implicit.stat_3593843976"] = "pseudo.pseudo_physical_attack_damage_leeched_as_life", + ["implicit.stat_3604946673"] = "pseudo.pseudo_total_additional_minion_gem_levels", + ["implicit.stat_3691695237"] = "pseudo.pseudo_total_additional_curse_gem_levels", + ["implicit.stat_387439868"] = "pseudo.pseudo_increased_elemental_damage_with_attack_skills", + ["implicit.stat_3917489142"] = "pseudo.pseudo_increased_rarity", + ["implicit.stat_3962278098"] = "pseudo.pseudo_increased_fire_damage", + ["implicit.stat_4043416969"] = "pseudo.pseudo_total_additional_lightning_gem_levels", + ["implicit.stat_4052037485"] = "pseudo.pseudo_total_energy_shield", + ["implicit.stat_4067062424"] = "pseudo.pseudo_adds_cold_damage_to_attacks", + ["implicit.stat_4080418644"] = "pseudo.pseudo_total_strength", + ["implicit.stat_4154259475"] = "pseudo.pseudo_total_additional_support_gem_levels", + ["implicit.stat_4208907162"] = "pseudo.pseudo_increased_lightning_damage_with_attack_skills", + ["implicit.stat_4220027924"] = "pseudo.pseudo_total_cold_resistance", + ["implicit.stat_524797741"] = "pseudo.pseudo_total_additional_skill_gem_levels", + ["implicit.stat_55876295"] = "pseudo.pseudo_physical_attack_damage_leeched_as_life", + ["implicit.stat_587431675"] = "pseudo.pseudo_global_critical_strike_chance", + ["implicit.stat_669069897"] = "pseudo.pseudo_physical_attack_damage_leeched_as_mana", + ["implicit.stat_674553446"] = "pseudo.pseudo_adds_chaos_damage_to_attacks", + ["implicit.stat_681332047"] = "pseudo.pseudo_total_attack_speed", + ["implicit.stat_709508406"] = "pseudo.pseudo_adds_fire_damage", + ["implicit.stat_737908626"] = "pseudo.pseudo_critical_strike_chance_for_spells", + ["implicit.stat_789117908"] = "pseudo.pseudo_increased_mana_regen", + ["implicit.stat_829382474"] = "pseudo.pseudo_total_additional_melee_gem_levels", + ["implicit.stat_860668586"] = "pseudo.pseudo_increased_cold_damage_with_attack_skills", + ["implicit.stat_916797432"] = "pseudo.pseudo_total_additional_strength_gem_levels", +} \ No newline at end of file