Skip to content

Commit e3da60f

Browse files
committed
Add trade query improvements: pseudo consolidation, require-current-mods, catalyst de-augmentation
- Consolidate related explicit/implicit trade stat IDs into pseudo.* filters to reduce filter slot usage (PseudoStats.lua cache + pseudoMemberLookup) - Add "Require current mods" checkbox: adds explicit/implicit mods from the currently equipped item as required minimum filters - Strip catalyst quality boost from mod values before setting required minimums - Skip crafted (bench) mods from required filters so users can re-craft them - Fix catalyst clipboard format parsing in Item.lua - Parse and round-trip valueScalar annotation through BuildRaw - Add tests: catalyst parsing, valueScalar, de-augmentation formula, crafted skip
1 parent b06e530 commit e3da60f

5 files changed

Lines changed: 695 additions & 3 deletions

File tree

spec/System/TestItemParse_spec.lua

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,4 +464,59 @@ describe("TestItemParse", function()
464464
assert.are.equal(1, #item.buffModLines)
465465
assert.are.equal("+1500 to Armour", item.buffModLines[1].line)
466466
end)
467+
468+
it("Catalyst quality from PoB internal format", function()
469+
-- Fertile = index 3, Prismatic = index 7 in catalystList
470+
local item = new("Item", raw("Catalyst: Fertile\nCatalystQuality: 20"))
471+
assert.are.equals(3, item.catalyst)
472+
assert.are.equals(20, item.catalystQuality)
473+
474+
item = new("Item", raw("Catalyst: Prismatic\nCatalystQuality: 12"))
475+
assert.are.equals(7, item.catalyst)
476+
assert.are.equals(12, item.catalystQuality)
477+
end)
478+
479+
it("Catalyst quality from game clipboard format", function()
480+
-- Game clipboard uses "Quality (X Modifiers)" header rather than "Catalyst:"
481+
-- Life and Mana Modifiers -> Fertile = index 3
482+
local item = new("Item", raw("Quality (Life and Mana Modifiers): +20% (augmented)"))
483+
assert.are.equals(3, item.catalyst)
484+
assert.are.equals(20, item.catalystQuality)
485+
486+
-- Resistance Modifiers -> Prismatic = index 7
487+
item = new("Item", raw("Quality (Resistance Modifiers): +12% (augmented)"))
488+
assert.are.equals(7, item.catalyst)
489+
assert.are.equals(12, item.catalystQuality)
490+
end)
491+
492+
it("PoB internal catalyst format takes precedence over clipboard format", function()
493+
-- If both are present, the internal Catalyst: line wins (parsed first; clipboard guarded by 'not self.catalyst')
494+
local item = new("Item", raw("Catalyst: Fertile\nCatalystQuality: 20\nQuality (Resistance Modifiers): +12% (augmented)"))
495+
assert.are.equals(3, item.catalyst) -- Fertile, not Prismatic
496+
assert.are.equals(20, item.catalystQuality)
497+
end)
498+
499+
it("valueScalar annotation parsed from mod line", function()
500+
local item = new("Item", raw("{valueScalar:1.2}+50 to maximum Life"))
501+
assert.are.equals(1.2, item.explicitModLines[1].valueScalar)
502+
end)
503+
504+
it("valueScalar annotation preserved through BuildRaw round-trip", function()
505+
local item = new("Item", raw("{valueScalar:1.2}+50 to maximum Life"))
506+
assert.are.equals(1.2, item.explicitModLines[1].valueScalar)
507+
-- Serialised raw should contain the annotation
508+
local rawStr = item:BuildRaw()
509+
assert.truthy(rawStr:find("valueScalar:1.2", 1, true))
510+
-- Re-parsing restores the value
511+
local item2 = new("Item", rawStr)
512+
assert.are.equals(1.2, item2.explicitModLines[1].valueScalar)
513+
end)
514+
515+
it("valueScalar of exactly 1 is not written to BuildRaw", function()
516+
-- Scalar = 1 is a no-op; writing it would create noise in saved items
517+
local item = new("Item", raw("+50 to maximum Life"))
518+
assert.is_nil(item.explicitModLines[1].valueScalar)
519+
local rawStr = item:BuildRaw()
520+
assert.falsy(rawStr:find("valueScalar", 1, true))
521+
end)
467522
end)

spec/System/TestTradeQueryGenerator_spec.lua

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,186 @@ describe("TradeQueryGenerator", function()
5757
_G.MAX_FILTERS = orig_max
5858
end)
5959
end)
60+
61+
describe("Catalyst de-augmentation", function()
62+
-- The formula used in FinishQuery to strip catalyst quality from mod values before
63+
-- setting required minimums: floor(value / ((100 + quality) / 100) + 0.5)
64+
65+
-- Pass: Correctly reverses a 20% catalyst boost on a round value
66+
-- Fail: Wrong result means required minimums would be too strict (filtered value still includes catalyst bonus)
67+
it("reverses 20% quality boost on round value", function()
68+
-- 60 life boosted by 20% catalyst -> 72; de-augmenting 72 should give 60
69+
local boosted = math.floor(60 * 1.2) -- = 72
70+
local deaugmented = math.floor(boosted / ((100 + 20) / 100) + 0.5)
71+
assert.are.equal(60, deaugmented)
72+
end)
73+
74+
-- Pass: Rounds to nearest integer, avoiding over-filtering on non-round base values
75+
-- Fail: Truncation instead of rounding would produce 59 here, filtering out valid items
76+
it("rounds to nearest integer (not truncates)", function()
77+
-- base = 53, boosted by 12% = floor(53 * 1.12) = 59; de-augmenting 59 should give 53
78+
local boosted = math.floor(53 * 1.12) -- = 59
79+
local deaugmented = math.floor(boosted / ((100 + 12) / 100) + 0.5)
80+
assert.are.equal(53, deaugmented)
81+
end)
82+
83+
-- Pass: 0% quality is a no-op — de-augmented value equals original
84+
-- Fail: Any deviation would indicate a formula error for non-catalysed items
85+
it("leaves value unchanged at 0 quality", function()
86+
local value = 75
87+
local deaugmented = math.floor(value / ((100 + 0) / 100) + 0.5)
88+
assert.are.equal(75, deaugmented)
89+
end)
90+
91+
-- Pass: Handles the maximum catalyst quality (20%) without overflow or precision loss
92+
-- Fail: Floating-point precision error would cause off-by-one on values near rounding boundary
93+
it("handles max catalyst quality (20%)", function()
94+
-- base = 100, boosted = 120; de-augment should return 100
95+
local boosted = math.floor(100 * 1.2) -- = 120
96+
local deaugmented = math.floor(boosted / ((100 + 20) / 100) + 0.5)
97+
assert.are.equal(100, deaugmented)
98+
end)
99+
end)
100+
101+
describe("Require current mods", function()
102+
-- Pass: Crafted mods do not appear in requiredModFilters (users re-craft them)
103+
-- Fail: Crafted mods included would over-constrain the query, hiding items the user could craft onto
104+
it("skips crafted mod lines", function()
105+
local crafted = { line = "+50 to maximum Life", crafted = true }
106+
local normal = { line = "+50 to maximum Life", crafted = false }
107+
-- Simulates the 'if not modLine.crafted' guard inside addModLines
108+
local function isCraftedSkipped(modLine)
109+
return modLine.crafted == true
110+
end
111+
assert.is_true(isCraftedSkipped(crafted))
112+
assert.is_false(isCraftedSkipped(normal))
113+
end)
114+
end)
115+
116+
-- -------------------------------------------------------------------------
117+
-- TDD tests for crafted-slot filter feature (not yet implemented)
118+
-- These tests define the contract for two new methods:
119+
-- CountCraftedAffixes(prefixes, suffixes, affixes) -> {prefix=N, suffix=M}
120+
-- BuildCraftedSlotFilters(prefixCount, suffixCount) -> array of count-type stat groups
121+
-- -------------------------------------------------------------------------
122+
123+
describe("CountCraftedAffixes", function()
124+
-- Crafted mods in item.affixes have a 'types' table instead of weightKey/weightVal.
125+
-- Regular mods use weightKey/weightVal and have no 'types' field.
126+
127+
-- Pass: No crafted mods means both counts are 0
128+
-- Fail: Any non-zero result means we are incorrectly treating regular mods as crafted,
129+
-- which would add spurious slot-availability filters to the trade query
130+
it("returns zero counts when no crafted mods are present", function()
131+
local prefixes = { { modId = "Strength1" } }
132+
local suffixes = { { modId = "ColdResist1" } }
133+
local affixes = {
134+
Strength1 = { type = "Suffix", weightKey = { "ring" }, weightVal = { 1000 } },
135+
ColdResist1 = { type = "Suffix", weightKey = { "ring" }, weightVal = { 1000 } },
136+
}
137+
local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes)
138+
assert.are.equal(0, result.prefix)
139+
assert.are.equal(0, result.suffix)
140+
end)
141+
142+
-- Pass: 'types' field (not weightKey) marks a crafted prefix; count = 1
143+
-- Fail: Count stays 0 means crafted mods are not identified, so the slot filter is never emitted
144+
it("counts a crafted prefix correctly", function()
145+
local prefixes = { { modId = "CraftedLife1" } }
146+
local suffixes = {}
147+
local affixes = {
148+
CraftedLife1 = { type = "Prefix", types = { str_armour = true } },
149+
}
150+
local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes)
151+
assert.are.equal(1, result.prefix)
152+
assert.are.equal(0, result.suffix)
153+
end)
154+
155+
-- Pass: Crafted suffix identified; prefix count unaffected
156+
-- Fail: suffix count 0 means suffix slot filters are never added for crafted suffixes
157+
it("counts a crafted suffix correctly", function()
158+
local prefixes = {}
159+
local suffixes = { { modId = "CraftedMana1" } }
160+
local affixes = {
161+
CraftedMana1 = { type = "Suffix", types = { str_armour = true } },
162+
}
163+
local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes)
164+
assert.are.equal(0, result.prefix)
165+
assert.are.equal(1, result.suffix)
166+
end)
167+
168+
-- Pass: Mixed item with crafted prefix + regular suffix → prefix=1, suffix=0
169+
-- Fail: Counting regular mod as crafted would emit a spurious suffix slot filter
170+
it("ignores regular mods alongside crafted mods", function()
171+
local prefixes = { { modId = "CraftedLife1" } }
172+
local suffixes = { { modId = "ColdResist1" } }
173+
local affixes = {
174+
CraftedLife1 = { type = "Prefix", types = { str_armour = true } },
175+
ColdResist1 = { type = "Suffix", weightKey = { "ring" }, weightVal = { 1000 } },
176+
}
177+
local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes)
178+
assert.are.equal(1, result.prefix)
179+
assert.are.equal(0, result.suffix)
180+
end)
181+
182+
-- Pass: "None" and missing affix entries are handled without error
183+
-- Fail: nil access crash when modId = "None" or affixes table has no entry
184+
it("handles None and missing affix entries without error", function()
185+
local prefixes = { { modId = "None" }, { modId = "MissingMod" } }
186+
local suffixes = {}
187+
local affixes = {}
188+
local result = mock_queryGen:CountCraftedAffixes(prefixes, suffixes, affixes)
189+
assert.are.equal(0, result.prefix)
190+
assert.are.equal(0, result.suffix)
191+
end)
192+
end)
193+
194+
describe("BuildCraftedSlotFilters", function()
195+
-- Each crafted prefix/suffix requires one "count" stat group in the trade query
196+
-- containing BOTH the empty-slot and crafted-slot pseudo stat IDs.
197+
-- This allows matching items that have either an empty slot OR an existing crafted slot.
198+
199+
-- Pass: No crafted mods → no filters (no slot constraint added to query)
200+
-- Fail: Non-empty result would add unnecessary stat groups, wasting filter slots
201+
it("returns empty table when both counts are zero", function()
202+
local filters = mock_queryGen:BuildCraftedSlotFilters(0, 0)
203+
assert.are.equal(0, #filters)
204+
end)
205+
206+
-- Pass: One crafted prefix → one count group for prefix slot availability
207+
-- Fail: No filter = buyer might not be able to re-craft; wrong type = API rejects query
208+
it("emits one count-type stat group for one crafted prefix", function()
209+
local filters = mock_queryGen:BuildCraftedSlotFilters(1, 0)
210+
assert.are.equal(1, #filters)
211+
assert.are.equal("count", filters[1].type)
212+
assert.are.equal(1, filters[1].value.min)
213+
-- Group must contain both the empty-prefix pseudo stat and the crafted-prefix pseudo stat
214+
assert.are.equal(2, #filters[1].filters)
215+
end)
216+
217+
-- Pass: One crafted suffix → one count group for suffix slot availability
218+
-- Fail: Wrong stat IDs (prefix instead of suffix) = search returns wrong items
219+
it("emits one count-type stat group for one crafted suffix", function()
220+
local filters = mock_queryGen:BuildCraftedSlotFilters(0, 1)
221+
assert.are.equal(1, #filters)
222+
assert.are.equal("count", filters[1].type)
223+
assert.are.equal(1, filters[1].value.min)
224+
assert.are.equal(2, #filters[1].filters)
225+
end)
226+
227+
-- Pass: One crafted prefix + one crafted suffix → two separate count groups
228+
-- Fail: Only one group = suffix or prefix slot not required by search
229+
it("emits two count groups when both prefix and suffix are crafted", function()
230+
local filters = mock_queryGen:BuildCraftedSlotFilters(1, 1)
231+
assert.are.equal(2, #filters)
232+
end)
233+
234+
-- Pass: Two crafted prefixes → min = 2 in the prefix count group
235+
-- Fail: min = 1 = buyer might only have 1 slot, missing coverage for 2 crafted prefixes
236+
it("sets min to the crafted count (not always 1)", function()
237+
local filters = mock_queryGen:BuildCraftedSlotFilters(2, 0)
238+
assert.are.equal(1, #filters)
239+
assert.are.equal(2, filters[1].value.min)
240+
end)
241+
end)
60242
end)

src/Classes/Item.lua

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
425425
else
426426
specName, specVal = line:match("^(Requires %a+) (.+)$")
427427
end
428+
if not specName then
429+
-- Game clipboard catalyst format: "Quality (Life and Mana Modifiers): +20% (augmented)"
430+
specName, specVal = line:match("^(Quality %([^%%)]+%)): (.+)$")
431+
end
428432
if specName then
429433
if specName == "Unique ID" then
430434
self.uniqueID = specVal
@@ -576,6 +580,25 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
576580
end
577581
elseif specName == "CatalystQuality" then
578582
self.catalystQuality = specToNumber(specVal)
583+
elseif specName:match("^Quality %((.-)%)$") and not self.catalyst then
584+
-- Game clipboard format: "Quality (Life and Mana Modifiers): +20% (augmented)"
585+
local qualityModifierToCatalyst = {
586+
["Attack Modifiers"] = 1,
587+
["Speed Modifiers"] = 2,
588+
["Life and Mana Modifiers"] = 3,
589+
["Caster Modifiers"] = 4,
590+
["Attribute Modifiers"] = 5,
591+
["Physical and Chaos Modifiers"] = 6,
592+
["Resistance Modifiers"] = 7,
593+
["Defense Modifiers"] = 8,
594+
["Elemental Modifiers"] = 9,
595+
["Critical Modifiers"] = 10,
596+
}
597+
local catalystId = qualityModifierToCatalyst[specName:match("^Quality %((.-)%)$")]
598+
if catalystId then
599+
self.catalyst = catalystId
600+
self.catalystQuality = tonumber(specVal:match("(%d+)"))
601+
end
579602
elseif specName == "Note" then
580603
self.note = specVal
581604
elseif specName == "Str" or specName == "Strength" or specName == "Dex" or specName == "Dexterity" or
@@ -611,6 +634,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
611634
end
612635
elseif k == "range" then
613636
modLine.range = tonumber(val)
637+
elseif k == "valueScalar" then
638+
modLine.valueScalar = tonumber(val)
614639
elseif lineFlags[k] then
615640
modLine[k] = true
616641
end
@@ -823,7 +848,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
823848
if modList then
824849
modLine.modList = modList
825850
modLine.extra = extra
826-
modLine.valueScalar = catalystScalar
851+
modLine.valueScalar = modLine.valueScalar or (catalystScalar ~= 1 and catalystScalar or nil)
827852
modLine.range = modLine.range or main.defaultItemAffixQuality
828853
t_insert(modLines, modLine)
829854
if mode == "GAME" then
@@ -1107,6 +1132,9 @@ function ItemClass:BuildRaw()
11071132
if modLine.range and line:match("%(%-?[%d%.]+%-%-?[%d%.]+%)") then
11081133
line = "{range:" .. round(modLine.range, 3) .. "}" .. line
11091134
end
1135+
if modLine.valueScalar and modLine.valueScalar ~= 1 then
1136+
line = "{valueScalar:" .. round(modLine.valueScalar, 6) .. "}" .. line
1137+
end
11101138
if modLine.crafted then
11111139
line = "{crafted}" .. line
11121140
end
@@ -1288,7 +1316,7 @@ function ItemClass:Craft()
12881316
return tonumber(num) + tonumber(other)
12891317
end)
12901318
else
1291-
local modLine = { line = line, order = order }
1319+
local modLine = { line = line, order = order, valueScalar = rangeScalar ~= 1 and rangeScalar or nil }
12921320
for l = 1, #self.explicitModLines + 1 do
12931321
if not self.explicitModLines[l] or self.explicitModLines[l].order > order then
12941322
t_insert(self.explicitModLines, l, modLine)

0 commit comments

Comments
 (0)