Skip to content
Merged
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
56 changes: 56 additions & 0 deletions spec/System/TestTradeHelpers_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ describe("TradeHelpers trade hash matching", function()
assert.equal(666, value)
end)

it("matches an abyss timeless jewel", function()
local tradeId, value = tradeHelpers.findTradeIdOption(
"Subjugating 533 souls in the thrall of Kurgal", "explicit")
assert.equal("explicit.pseudo_timeless_jewel_kurgal", tradeId)
assert.equal(533, value)

local tradeId, value = tradeHelpers.findTradeIdOption(
"Binding 6564 souls to phylacteries to sustain Zorath", "explicit")
assert.equal("explicit.pseudo_timeless_jewel_zorath", tradeId)
assert.equal(6564, value)
end)

it("returns nil for an unmatchable line", function()
assert.is_nil(tradeHelpers.findTradeIdOption("+100 to IQ", "explicit"))
end)
Expand Down Expand Up @@ -97,6 +109,7 @@ describe("TradeHelpers trade hash matching", function()
assert.is_true(shouldNegate)
assert.equal(1, #ids)
end)

it("detects mods with lua pattern characters correctly", function()
local ids, value = tradeHelpers.findTradeHash(
"trigger Socketed Spells when you focus, with a 0.25 second cooldown")
Expand All @@ -109,6 +122,49 @@ describe("TradeHelpers trade hash matching", function()
assert.equal(10, value)
assert.is_true(shouldNegate)
end)

it("picks the canonical stat when the descriptor orders values right to left", function()
-- Kitava's Thirst's vestigial implicit. its descriptor is "{1}% chance ... {0} Mana ...",
-- so the canonical stat (2) is the first value in the text, not the second
local ids, value = tradeHelpers.findTradeHash(
"25% chance to Trigger Socketed Spells when you Spend at least 100 Mana on an\nUpfront Cost to Use or Trigger a Skill, with a 0.1 second Cooldown")
assert.is_truthy(isValueInArray(ids,
HashStats({ "cast_socketed_spells_on_X_mana_spent", "cast_socketed_spells_on_mana_spent_%_chance" })))
assert.equal(25, value)
end)

it("picks the canonical stat on a single line right to left descriptor", function()
-- "{1}% increased Movement Speed for {0} seconds on Throwing a Trap"
local ids, value = tradeHelpers.findTradeHash("15% increased Movement Speed for 9 seconds on Throwing a Trap")
assert.is_truthy(isValueInArray(ids,
HashStats({ "movement_speed_bonus_when_throwing_trap_ms", "movement_speed_+%_on_throwing_trap" })))
assert.equal(15, value)
end)

it("uses the value implied by the limits when the canonical stat has no value group", function()
-- this form is only used when the chance is at least 100, so the chance is left out of
-- the text even though it is the canonical stat
local ids, value = tradeHelpers.findTradeHash("Gain Unholy Might on Block for 10 seconds")
assert.is_truthy(isValueInArray(ids,
HashStats({ "chance_to_gain_unholy_might_on_block_%", "chance_to_gain_unholy_might_on_block_ms" })))
assert.equal(100, value)
end)

it("does not repeat a hash when several forms of one stat match", function()
local ids = tradeHelpers.findTradeHash("Regenerate 5% of Life per second")
assert.equal(1, #ids)
end)

it("matches a descriptor with an indexed group and no canonical stat flag", function()
-- this descriptor bundles four stats and only renders {2}, with no canonical stat flag
local ids, value = tradeHelpers.findTradeHash(
"Skills supported by Unleash have +1 to maximum number of Seals")
assert.is_truthy(isValueInArray(ids, HashStats({ "support_anticipation_charge_gain_interval_ms",
"virtual_support_anticipation_charge_gain_interval_ms", "support_anticipation_rapid_fire_count",
"skill_max_unleash_seals" })))
assert.equal(1, value)
end)

it("detects passage modline correctly", function()
local ids = tradeHelpers.findTradeHash(
"Passive Skills in Radius can be Allocated without being connected to your tree")
Expand Down
77 changes: 68 additions & 9 deletions src/Classes/TradeHelpers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,33 @@ local m_floor = math.floor
local numberPattern = "%%d%+%%.%?%%d*"
local statDescData
local function getStatDescData()
-- this is not perfect. some currently known issues include:
-- death's oath chaos damage line: formatted as a # to # value on trade site which shows 3 to 450. nonsensical
-- life flask: immunity frozen/chill. probably caused by pob splitting the mods
if statDescData then return statDescData end
statDescData = LoadModule("Data/StatDescriptions/stat_descriptions")
for _, statDescEntry in ipairs(statDescData) do
for _, desc in ipairs(statDescEntry[1] or {}) do
-- stat descriptors don't necessarily have the stats ordered from
-- left to right. for example a text might have {2} {1}. in this
-- case if it also has a canonical stat defined, we need to keep
-- track of these group ids to know where the stat actually is. See
-- for example:
-- "{1}% chance to Trigger Socketed Spells when you Spend at least {0} Life on an\nUpfront Cost to Use or Trigger a Skill, with a 0.1 second Cooldown"
local groupIndexes = {}
local leftToRightIdx = 1
for valueGroup in desc.text:gmatch("{.-}") do
local groupIdx = valueGroup:match("{(%d+):?.*}")
if groupIdx then
table.insert(groupIndexes,
-- canonical stat is 1 indexed while group ids are zero indexed
tonumber(groupIdx) + 1)
else
table.insert(groupIndexes, leftToRightIdx)
end
leftToRightIdx = leftToRightIdx + 1
end
desc.groupIndexes = groupIndexes
-- pob doesn't parse this as a part of other mods
desc.text = desc.text:gsub("\nPassage", "")
desc.pat = desc.text
Expand All @@ -35,6 +58,7 @@ local function getStatDescData()
-- stats do require the plus sign to parse, but this simplifies handling reflected
-- mods
"%%%+%?(%%%-%?" .. numberPattern .. ")")
desc.pat = "^" .. desc.pat .. "$"
end
end
return statDescData
Expand Down Expand Up @@ -166,7 +190,9 @@ function M.findTradeIdOption(modLine, modType)
"commanded leadership over (%d+) warriors under (.+)",
"commissioned (%d+) coins to commemorate (.+)",
"denoted service of (%d+) dekhara in the akhara of (.+)",
"remembrancing (%d+) songworthy deeds by the line of (.+)" }
"remembrancing (%d+) songworthy deeds by the line of (.+)",
"subjugating (%d+) souls in the thrall of (.+)",
"binding (%d+) souls to phylacteries to sustain (.+)" }
for _, pat in ipairs(timelessPatterns) do
local value, conqueror = modLine:match(pat)
if conqueror then
Expand Down Expand Up @@ -195,6 +221,21 @@ function M.findTradeIdOption(modLine, modType)
end
end

-- some forms leave the trade site stat out of their text entirely, because that
-- form is only used when the stat has a known value, which is recorded in the
-- form's limits. this is for example common with % chance, where 100% chance
-- omits the chance text
---@return number? value
local function impliedValue(statForm, canonical_stat)
local limit = statForm.limit and statForm.limit[canonical_stat or 1]
return limit and tonumber(limit[1])
end

local function insertUniqueHash(resultIds, tradeHash)
if not isValueInArray(resultIds, tradeHash) then
table.insert(resultIds, tradeHash)
end
end
-- Helper: find the trade stat ID for a mod line
---@param modLine string
---@return table[] results Can include more than one result if the results are ambiguous
Expand Down Expand Up @@ -225,10 +266,9 @@ function M.findTradeHash(modLine)
end
-- by default, the trade site uses the first form listed in the stat descriptions, but there
-- can be a flag that says otherwise
-- local canonical_line = 1
-- the stat descriptions default to using the first stat for the trade site, but this
-- flag can define it to be another one
local canonical_stat = 1
local canonical_stat
local canonical_negated = false
for statFormIdx, statForm in ipairs(statDescriptions) do
local negate = false
Expand All @@ -255,28 +295,47 @@ function M.findTradeHash(modLine)
-- stat has no variables
if modLine == statForm.text:lower() then
local tradeHash = HashStats(statDescEntry.stats, extraStat)
table.insert(resultIds, tradeHash)
insertUniqueHash(resultIds, tradeHash)
shouldNegate = false
-- it's hard to know the correct value, but many stats have a form with no variables when the chance to do something is 100%. this should assign a value for those
value = tonumber(statForm.limit and statForm.limit[1] and statForm.limit[1][1])
value = impliedValue(statForm, canonical_stat)
goto continue
end
-- ensure no false positives by requiring a full line match. this is not possible in gmatch as it doesn't support ^
if modLine:match("^" .. statForm.pat .. "$") then
if modLine:match(statForm.pat) then
local idx = 1
for match in modLine:gmatch(statForm.pat) do
local matchedCanonical = false
local matches = { modLine:match(statForm.pat) }
for _, match in ipairs(matches) do
-- note that if the desired value isn't the first match and this is a # to #,
-- this will break as it contains two values. however, there is only a single
-- example where # to # are not the first two values currently
local number = tonumber(match) or M.modLineValue(match)
if number and idx == canonical_stat then
-- we assume that the desired trade value is either the
-- first value from the left, or defined by a canonical_stat
-- flag, which refers to the ids which are in the stat
-- descriptor, e.g. {2}
if number and (not canonical_stat) or (statForm.groupIndexes[idx] == canonical_stat) then
shouldNegate = negate ~= canonical_negated
local tradeHash = HashStats(statDescEntry.stats, extraStat)
table.insert(resultIds, tradeHash)
insertUniqueHash(resultIds, tradeHash)
value = number
matchedCanonical = true
break
end
idx = idx + 1
end
-- the canonical stat is missing from the form text: take it
-- from the limits. this can be e.g. 100% chance omitting the
-- chance.
if canonical_stat and not matchedCanonical then
local implied = impliedValue(statForm, canonical_stat)
if implied then
shouldNegate = negate ~= canonical_negated
insertUniqueHash(resultIds, HashStats(statDescEntry.stats, extraStat))
value = implied
end
end
end
end
::continue::
Expand Down
Loading