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
77 changes: 77 additions & 0 deletions spec/System/TestStonefist_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
-- Tests for the "Way of the Stonefist" ascendancy passive (Martial Artist): equipped
-- Gloves transform to the "Fists of Stone" base, renaming the base, converting their
-- explicit mods to stronger related mods, and granting per-level Evasion/Energy Shield.
describe("TestStonefist", function()
before_each(function()
newBuild()
end)

teardown(function()
-- newBuild() takes care of resetting everything in setup()
end)

-- "+38 to maximum Energy Shield" is tier LocalIncreasedEnergyShield5 (36-41), which
-- the map converts to +3 Evasion and +1 Energy Shield per level.
local function makeVariant(baseName, level)
local item = new("Item", "New Item\n" .. baseName .. "\n+38 to maximum Energy Shield")
return item, item:CreateStonefistVariant(level)
end

it("renames the transformed base to Fists of Stone", function()
local _, variant = makeVariant("Sombre Gloves")
assert.is_truthy(variant)
assert.are.equals("Fists of Stone", variant.baseName)
end)

it("converts a flat Energy Shield prefix into per-level defences", function()
local _, variant = makeVariant("Sombre Gloves")
-- flat Energy Shield is gone, replaced by per-level Evasion/Energy Shield
assert.are.equals(0, variant.baseModList:Sum("BASE", nil, "EnergyShield"))
assert.is_true(variant.baseModList:Sum("BASE", nil, "EvasionPerLevel") > 0)
assert.is_true(variant.baseModList:Sum("BASE", nil, "EnergyShieldPerLevel") > 0)
end)

it("adds the Fists of Stone base implicit per-level defences", function()
-- a glove with no defensive affix still gains the +3 Eva / +1 ES per level implicit
local item = new("Item", "New Item\nSombre Gloves\n+38 to maximum Life")
local variant = item:CreateStonefistVariant()
assert.is_truthy(variant)
assert.is_true(variant.baseModList:Sum("BASE", nil, "EvasionPerLevel") >= 3)
assert.is_true(variant.baseModList:Sum("BASE", nil, "EnergyShieldPerLevel") >= 1)
end)

it("combines base implicit and converted item mods in the level-scaled defence value", function()
local _, variant = makeVariant("Sombre Gloves")
-- implicit (+3 Eva / +1 ES) + converted mod (+3 Eva / +1 ES) per level, at level 100,
-- excluding the global "% more" modifier
assert.are.equals(600, variant:GetArmourDataValue("Evasion", 100))
assert.are.equals(200, variant:GetArmourDataValue("EnergyShield", 100))
assert.are.equals(0, variant:GetArmourDataValue("Ward", 100))
end)

it("uses the Runeforged Fists of Stone implicit (with Runic Ward) for runeforged bases", function()
local _, variant = makeVariant("Runeforged Sombre Gloves")
-- runeforged implicit (+2 Eva / +1 ES / +1 Ward) + converted mod (+3 Eva / +1 ES) per level
assert.are.equals(500, variant:GetArmourDataValue("Evasion", 100))
assert.are.equals(200, variant:GetArmourDataValue("EnergyShield", 100))
assert.are.equals(100, variant:GetArmourDataValue("Ward", 100))
end)

it("resolves per-level display strings at the given character level", function()
local _, variant = makeVariant("Sombre Gloves", 100)
-- with a display level, "per player level" implicits resolve to concrete values (+3 * 100)
local resolved = false
for _, line in ipairs(variant.implicitModLines) do
if line.line:find("+300 to Evasion Rating", 1, true) then
resolved = true
end
assert.is_nil(line.line:find("per player level", 1, true))
end
assert.is_true(resolved)
end)

it("returns nil for items that are not armour gloves", function()
local ring = new("Item", "New Item\nSapphire Ring")
assert.is_nil(ring:CreateStonefistVariant())
end)
end)
196 changes: 195 additions & 1 deletion src/Classes/Item.lua
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,80 @@ local function getCatalystScalar(catalystId, mod, quality)
return 1
end

local function stonefistAverageRanges(text)
return (text:gsub("%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)", function(a, b)
return tostring(m_floor((tonumber(a) + tonumber(b)) / 2 + 0.5))
end))
end

local function stonefistComputePerLevel(text, displayLevel)
if displayLevel then
local value, label = text:match("^Has ([%+%-]?%d+) (.+) per player level$")
if value then
local total = tonumber(value) * displayLevel
return (total >= 0 and "+" or "") .. total .. " " .. label
end
end
return text
end

local function stonefistSlotify(s)
local slots = { }
local out = { }
local pos, len = 1, #s
while pos <= len do
local rs, re, a, b = s:find("^%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)", pos)
if rs then
t_insert(slots, { tonumber(a), tonumber(b) })
t_insert(out, "#")
pos = re + 1
else
local ns, ne, n = s:find("^(%-?%d+%.?%d*)", pos)
if ns then
t_insert(slots, { tonumber(n), tonumber(n) })
t_insert(out, "#")
pos = ne + 1
else
t_insert(out, s:sub(pos, pos))
pos = pos + 1
end
end
end
return table.concat(out), slots
end

local stonefistBaseName = "Fists of Stone"

local stonefistSourceIndexCache = setmetatable({ }, { __mode = "k" })
local function getStonefistSourceIndex(map, affixes)
local index = stonefistSourceIndexCache[affixes]
if index then
return index
end
index = { }
for modId in pairs(map) do
local modData = affixes[modId]
if modData then
for i = 1, #modData do
if type(modData[i]) == "string" then
local tmpl, slots = stonefistSlotify(modData[i])
local bucket = index[tmpl]
if not bucket then
bucket = { }
index[tmpl] = bucket
end
bucket[#bucket + 1] = { id = modId, slots = slots }
end
end
end
end
for _, bucket in pairs(index) do
table.sort(bucket, function(a, b) return a.id < b.id end)
end
stonefistSourceIndexCache[affixes] = index
return index
end

local ItemClass = newClass("Item", function(self, raw, rarity, highQuality)
if raw then
self:ParseRaw(sanitiseText(raw), rarity, highQuality)
Expand Down Expand Up @@ -303,6 +377,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
self.spiritValue = nil
self.runicItem = nil
self.quality = nil
self.stonefistVariantCache = nil
self.rawLines = { }
-- Find non-blank lines and trim whitespace
for line in raw:gmatch("%s*([^\n]*%S)") do
Expand Down Expand Up @@ -727,6 +802,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
modLine.range = tonumber(val)
elseif k == "corruptedRange" then
modLine.corruptedRange = tonumber(val)
elseif k == "modId" then
modLine.modId = val
elseif lineFlags[k] then
modLine[k] = true
end
Expand Down Expand Up @@ -1402,6 +1479,9 @@ function ItemClass:BuildRaw()
if modLine.corruptedRange then
line = "{corruptedRange:" .. round(modLine.corruptedRange, 2) .. "}" .. line
end
if modLine.modId then
line = "{modId:" .. modLine.modId .. "}" .. line
end
if modLine.rune then
line = "{rune}" .. line
end
Expand Down Expand Up @@ -1543,6 +1623,120 @@ function ItemClass:BuildAndParseRaw()
self:ParseRaw(raw)
end

function ItemClass:ResolveStonefistModId(modLine)
local map = data.stonefistMap
local affixes = self.affixes
if not map or not affixes then
return nil
end
local index = getStonefistSourceIndex(map, affixes)
for part in (modLine.line .. "\n"):gmatch("(.-)\n") do
local stat = part:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
if stat ~= "" then
local lineTmpl, lineSlots = stonefistSlotify(stat)
local bucket = index[lineTmpl]
if bucket then
for _, cand in ipairs(bucket) do
local statSlots = cand.slots
local ok = true
for j = 1, #statSlots do
local v = lineSlots[j][1]
if v < m_min(statSlots[j][1], statSlots[j][2]) or v > m_max(statSlots[j][1], statSlots[j][2]) then
ok = false
break
end
end
if ok then
return cand.id
end
end
end
end
end
return nil
end

function ItemClass:CreateStonefistVariant(displayLevel)
local map = data.stonefistMap
if not map then
return nil
end
local cacheKey = displayLevel or 0
local cache = self.stonefistVariantCache
if cache then
local cached = cache[cacheKey]
if cached ~= nil then
return cached or nil
end
else
cache = { }
self.stonefistVariantCache = cache
end
local clone = new("Item", self:BuildRaw())
local newExplicit = { }
local emitted = { }
local changed = false
for _, modLine in ipairs(clone.explicitModLines) do
local modId = modLine.modId
if not (modId and map[modId]) then
modId = clone:ResolveStonefistModId(modLine)
end
if modId and map[modId] then
changed = true
if not emitted[modId] then
emitted[modId] = true
local entry = data.itemMods.FistsOfStone[map[modId]]
if entry then
for i = 1, #entry do
if type(entry[i]) == "string" then
local text = stonefistAverageRanges(entry[i])
local list, extra = modLib.parseMod(text)
t_insert(newExplicit, { line = stonefistComputePerLevel(text, displayLevel), modList = list or { }, extra = extra })
end
end
end
end
else
t_insert(newExplicit, modLine)
end
end
if clone.base and clone.base.armour then
-- Runeforged/runemastered (Runic Ward) bases use the Runeforged Fists of Stone implicit.
local hasWard = clone.runicItem or (clone.base.armour.Ward or 0) > 0
local armourCopy = { }
for k, v in pairs(clone.base.armour) do
armourCopy[k] = v
end
armourCopy.Armour, armourCopy.Evasion, armourCopy.EnergyShield, armourCopy.Ward = nil, nil, nil, nil
clone.base = setmetatable({ armour = armourCopy }, { __index = clone.base })
-- The transformed base has no flat defences; its per-level defence is the base implicit
-- of the Fists of Stone base (read from the base data so it stays in sync).
local fistsBase = data.itemBases[hasWard and "Runeforged Fists of Stone" or "Fists of Stone"]
if fistsBase and fistsBase.implicit then
local lines = { }
for implicitLine in (fistsBase.implicit .. "\n"):gmatch("(.-)\n") do
if implicitLine ~= "" then
t_insert(lines, implicitLine)
end
end
for i = #lines, 1, -1 do
local list, extra = modLib.parseMod(lines[i])
t_insert(clone.implicitModLines, 1, { line = stonefistComputePerLevel(lines[i], displayLevel), modList = list or { }, extra = extra })
end
end
changed = true
end
if not changed then
cache[cacheKey] = false
return nil
end
clone.explicitModLines = newExplicit
clone:BuildModList()
clone.baseName = stonefistBaseName
cache[cacheKey] = clone
return clone
end

-- Rebuild rune modifiers using the item's runes
function ItemClass:UpdateRunes()
wipeTable(self.runeModLines)
Expand Down Expand Up @@ -1666,7 +1860,7 @@ function ItemClass:Craft()
return tonumber(num) + tonumber(other)
end)
else
local modLine = { line = line, order = order }
local modLine = { line = line, order = order, modId = affix.modId }
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