Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions spec/System/TestItemParse_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
240 changes: 240 additions & 0 deletions spec/System/TestTradeQueryGenerator_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,244 @@
_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)

Check warning on line 70 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)

Check warning on line 70 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)
assert.are.equal(60, deaugmented)

Check warning on line 71 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)

Check warning on line 71 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (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)

Check warning on line 79 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)

Check warning on line 79 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)
assert.are.equal(53, deaugmented)

Check warning on line 80 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)

Check warning on line 80 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (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)

Check warning on line 87 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)

Check warning on line 87 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (deaugmented)
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)

-- Pass: Each individual stat in the count group has value.min = 1 so the trade API
-- evaluates them correctly. Without this the filter silently matches everything.
-- Fail: Missing value field = trade site ignores the stat, returning uncrafted items

Check warning on line 244 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (uncrafted)

Check warning on line 244 in spec/System/TestTradeQueryGenerator_spec.lua

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (uncrafted)
it("each stat inside the count group has value.min = 1", function()
local filters = mock_queryGen:BuildCraftedSlotFilters(1, 0)
assert.are.equal(1, #filters)
for _, stat in ipairs(filters[1].filters) do
assert.is_not_nil(stat.value, "stat missing value field: " .. tostring(stat.id))
assert.are.equal(1, stat.value.min)
end
end)
end)

describe("CountCraftedAffixesFromModLines", function()
-- Uses the item's explicitModLines (modLine.crafted = true) rather than the affix
-- slot tables, which is the only reliable source for PoB-crafted items.
-- Pool entries: numeric-indexed text lines + type (string) + types (table, marks crafted).

-- Synthetic affix pool covering both a crafted suffix and a crafted prefix.
local syntheticPool = {
CraftedMana1 = { "(25-34) to maximum Mana", type = "Suffix", types = { str = true } },
CraftedArmour1 = { "(30-40)% increased Armour", type = "Prefix", types = { str = true } },
}

-- Pass: A crafted suffix mod line matches the pool and suffixCount = 1
-- Fail: Count stays 0 = crafted mod not matched, no slot filter emitted
it("counts a crafted suffix mod line", function()
local modLines = {
{ line = "+29 to maximum Mana", crafted = true },
{ line = "+45 to maximum Energy Shield", crafted = false },
}
local result = mock_queryGen:CountCraftedAffixesFromModLines(modLines, syntheticPool, nil)
assert.are.equal(0, result.prefix)
assert.are.equal(1, result.suffix)
end)

-- Pass: A crafted prefix mod line matches the pool and prefixCount = 1
-- Fail: prefix count stays 0 = Prefix type not recognised, filter uses wrong slot
it("counts a crafted prefix mod line", function()
local modLines = {
{ line = "35% increased Armour", crafted = true },
}
local result = mock_queryGen:CountCraftedAffixesFromModLines(modLines, syntheticPool, nil)
assert.are.equal(1, result.prefix)
assert.are.equal(0, result.suffix)
end)

-- Pass: Non-crafted mod lines are ignored; counts stay zero
-- Fail: Any non-zero count means regular mods trigger spurious slot filters
it("ignores non-crafted mod lines", function()
local modLines = {
{ line = "+45 to maximum Energy Shield", crafted = false },
}
local result = mock_queryGen:CountCraftedAffixesFromModLines(modLines, syntheticPool, nil)
assert.are.equal(0, result.prefix)
assert.are.equal(0, result.suffix)
end)
end)
end)
34 changes: 32 additions & 2 deletions src/Classes/Item.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ local catalystTags = {
{ "jewellery_elemental" ,"elemental_damage" },
{ "critical" },
}
-- Maps game clipboard "Quality (X Modifiers)" label to catalyst index (same order as catalystList/catalystTags).
local catalystQualityModifier = {
["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 function getCatalystScalar(catalystId, tags, quality)
if not catalystId or type(catalystId) ~= "number" or not catalystTags[catalystId] or not tags or type(tags) ~= "table" or #tags == 0 then
Expand Down Expand Up @@ -425,6 +438,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
Expand Down Expand Up @@ -576,6 +593,14 @@ 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 qualityModType = specName:match("^Quality %((.-)%)$")
local catalystId = catalystQualityModifier[qualityModType]
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
Expand Down Expand Up @@ -611,6 +636,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
Expand Down Expand Up @@ -823,7 +850,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
Expand Down Expand Up @@ -1107,6 +1134,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
Expand Down Expand Up @@ -1288,7 +1318,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)
Expand Down
Loading
Loading