Skip to content
Closed
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)
182 changes: 182 additions & 0 deletions spec/System/TestTradeQueryGenerator_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
32 changes: 30 additions & 2 deletions src/Classes/Item.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading