Skip to content

Commit 8fbecc1

Browse files
committed
Add logic for way of the stonefist ascendency
1 parent 11337ed commit 8fbecc1

17 files changed

Lines changed: 4239 additions & 2747 deletions

spec/System/TestStonefist_spec.lua

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
-- Tests for the "Way of the Stonefist" ascendancy passive (Martial Artist): equipped
2+
-- Gloves transform to the "Fists of Stone" base, renaming the base, converting their
3+
-- explicit mods to stronger related mods, and granting per-level Evasion/Energy Shield.
4+
describe("TestStonefist", function()
5+
before_each(function()
6+
newBuild()
7+
end)
8+
9+
teardown(function()
10+
-- newBuild() takes care of resetting everything in setup()
11+
end)
12+
13+
-- "+38 to maximum Energy Shield" is tier LocalIncreasedEnergyShield5 (36-41), which
14+
-- the map converts to +3 Evasion and +1 Energy Shield per level.
15+
local function makeVariant(baseName, level)
16+
local item = new("Item", "New Item\n" .. baseName .. "\n+38 to maximum Energy Shield")
17+
return item, item:CreateStonefistVariant(level)
18+
end
19+
20+
it("renames the transformed base to Fists of Stone", function()
21+
local _, variant = makeVariant("Sombre Gloves")
22+
assert.is_truthy(variant)
23+
assert.are.equals("Fists of Stone", variant.baseName)
24+
end)
25+
26+
it("converts a flat Energy Shield prefix into per-level defences", function()
27+
local _, variant = makeVariant("Sombre Gloves")
28+
-- flat Energy Shield is gone, replaced by per-level Evasion/Energy Shield
29+
assert.are.equals(0, variant.baseModList:Sum("BASE", nil, "EnergyShield"))
30+
assert.is_true(variant.baseModList:Sum("BASE", nil, "EvasionPerLevel") > 0)
31+
assert.is_true(variant.baseModList:Sum("BASE", nil, "EnergyShieldPerLevel") > 0)
32+
end)
33+
34+
it("adds the Fists of Stone base implicit per-level defences", function()
35+
-- a glove with no defensive affix still gains the +3 Eva / +1 ES per level implicit
36+
local item = new("Item", "New Item\nSombre Gloves\n+38 to maximum Life")
37+
local variant = item:CreateStonefistVariant()
38+
assert.is_truthy(variant)
39+
assert.is_true(variant.baseModList:Sum("BASE", nil, "EvasionPerLevel") >= 3)
40+
assert.is_true(variant.baseModList:Sum("BASE", nil, "EnergyShieldPerLevel") >= 1)
41+
end)
42+
43+
it("combines base implicit and converted item mods in the level-scaled defence value", function()
44+
local _, variant = makeVariant("Sombre Gloves")
45+
-- implicit (+3 Eva / +1 ES) + converted mod (+3 Eva / +1 ES) per level, at level 100,
46+
-- excluding the global "% more" modifier
47+
assert.are.equals(600, variant:GetArmourDataValue("Evasion", 100))
48+
assert.are.equals(200, variant:GetArmourDataValue("EnergyShield", 100))
49+
assert.are.equals(0, variant:GetArmourDataValue("Ward", 100))
50+
end)
51+
52+
it("uses the Runeforged Fists of Stone implicit (with Runic Ward) for runeforged bases", function()
53+
local _, variant = makeVariant("Runeforged Sombre Gloves")
54+
-- runeforged implicit (+2 Eva / +1 ES / +1 Ward) + converted mod (+3 Eva / +1 ES) per level
55+
assert.are.equals(500, variant:GetArmourDataValue("Evasion", 100))
56+
assert.are.equals(200, variant:GetArmourDataValue("EnergyShield", 100))
57+
assert.are.equals(100, variant:GetArmourDataValue("Ward", 100))
58+
end)
59+
60+
it("resolves per-level display strings at the given character level", function()
61+
local _, variant = makeVariant("Sombre Gloves", 100)
62+
-- with a display level, "per player level" implicits resolve to concrete values (+3 * 100)
63+
local resolved = false
64+
for _, line in ipairs(variant.implicitModLines) do
65+
if line.line:find("+300 to Evasion Rating", 1, true) then
66+
resolved = true
67+
end
68+
assert.is_nil(line.line:find("per player level", 1, true))
69+
end
70+
assert.is_true(resolved)
71+
end)
72+
73+
it("returns nil for items that are not armour gloves", function()
74+
local ring = new("Item", "New Item\nSapphire Ring")
75+
assert.is_nil(ring:CreateStonefistVariant())
76+
end)
77+
end)

src/Classes/Item.lua

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,80 @@ local function getCatalystScalar(catalystId, mod, quality)
5858
return 1
5959
end
6060

61+
local function stonefistAverageRanges(text)
62+
return (text:gsub("%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)", function(a, b)
63+
return tostring(m_floor((tonumber(a) + tonumber(b)) / 2 + 0.5))
64+
end))
65+
end
66+
67+
local function stonefistComputePerLevel(text, displayLevel)
68+
if displayLevel then
69+
local value, label = text:match("^Has ([%+%-]?%d+) (.+) per player level$")
70+
if value then
71+
local total = tonumber(value) * displayLevel
72+
return (total >= 0 and "+" or "") .. total .. " " .. label
73+
end
74+
end
75+
return text
76+
end
77+
78+
local function stonefistSlotify(s)
79+
local slots = { }
80+
local out = { }
81+
local pos, len = 1, #s
82+
while pos <= len do
83+
local rs, re, a, b = s:find("^%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)", pos)
84+
if rs then
85+
t_insert(slots, { tonumber(a), tonumber(b) })
86+
t_insert(out, "#")
87+
pos = re + 1
88+
else
89+
local ns, ne, n = s:find("^(%-?%d+%.?%d*)", pos)
90+
if ns then
91+
t_insert(slots, { tonumber(n), tonumber(n) })
92+
t_insert(out, "#")
93+
pos = ne + 1
94+
else
95+
t_insert(out, s:sub(pos, pos))
96+
pos = pos + 1
97+
end
98+
end
99+
end
100+
return table.concat(out), slots
101+
end
102+
103+
local stonefistBaseName = "Fists of Stone"
104+
105+
local stonefistSourceIndexCache = setmetatable({ }, { __mode = "k" })
106+
local function getStonefistSourceIndex(map, affixes)
107+
local index = stonefistSourceIndexCache[affixes]
108+
if index then
109+
return index
110+
end
111+
index = { }
112+
for modId in pairs(map) do
113+
local modData = affixes[modId]
114+
if modData then
115+
for i = 1, #modData do
116+
if type(modData[i]) == "string" then
117+
local tmpl, slots = stonefistSlotify(modData[i])
118+
local bucket = index[tmpl]
119+
if not bucket then
120+
bucket = { }
121+
index[tmpl] = bucket
122+
end
123+
bucket[#bucket + 1] = { id = modId, slots = slots }
124+
end
125+
end
126+
end
127+
end
128+
for _, bucket in pairs(index) do
129+
table.sort(bucket, function(a, b) return a.id < b.id end)
130+
end
131+
stonefistSourceIndexCache[affixes] = index
132+
return index
133+
end
134+
61135
local ItemClass = newClass("Item", function(self, raw, rarity, highQuality)
62136
if raw then
63137
self:ParseRaw(sanitiseText(raw), rarity, highQuality)
@@ -303,6 +377,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
303377
self.spiritValue = nil
304378
self.runicItem = nil
305379
self.quality = nil
380+
self.stonefistVariantCache = nil
306381
self.rawLines = { }
307382
-- Find non-blank lines and trim whitespace
308383
for line in raw:gmatch("%s*([^\n]*%S)") do
@@ -727,6 +802,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
727802
modLine.range = tonumber(val)
728803
elseif k == "corruptedRange" then
729804
modLine.corruptedRange = tonumber(val)
805+
elseif k == "modId" then
806+
modLine.modId = val
730807
elseif lineFlags[k] then
731808
modLine[k] = true
732809
end
@@ -1402,6 +1479,9 @@ function ItemClass:BuildRaw()
14021479
if modLine.corruptedRange then
14031480
line = "{corruptedRange:" .. round(modLine.corruptedRange, 2) .. "}" .. line
14041481
end
1482+
if modLine.modId then
1483+
line = "{modId:" .. modLine.modId .. "}" .. line
1484+
end
14051485
if modLine.rune then
14061486
line = "{rune}" .. line
14071487
end
@@ -1543,6 +1623,120 @@ function ItemClass:BuildAndParseRaw()
15431623
self:ParseRaw(raw)
15441624
end
15451625

1626+
function ItemClass:ResolveStonefistModId(modLine)
1627+
local map = data.stonefistMap
1628+
local affixes = self.affixes
1629+
if not map or not affixes then
1630+
return nil
1631+
end
1632+
local index = getStonefistSourceIndex(map, affixes)
1633+
for part in (modLine.line .. "\n"):gmatch("(.-)\n") do
1634+
local stat = part:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
1635+
if stat ~= "" then
1636+
local lineTmpl, lineSlots = stonefistSlotify(stat)
1637+
local bucket = index[lineTmpl]
1638+
if bucket then
1639+
for _, cand in ipairs(bucket) do
1640+
local statSlots = cand.slots
1641+
local ok = true
1642+
for j = 1, #statSlots do
1643+
local v = lineSlots[j][1]
1644+
if v < m_min(statSlots[j][1], statSlots[j][2]) or v > m_max(statSlots[j][1], statSlots[j][2]) then
1645+
ok = false
1646+
break
1647+
end
1648+
end
1649+
if ok then
1650+
return cand.id
1651+
end
1652+
end
1653+
end
1654+
end
1655+
end
1656+
return nil
1657+
end
1658+
1659+
function ItemClass:CreateStonefistVariant(displayLevel)
1660+
local map = data.stonefistMap
1661+
if not map then
1662+
return nil
1663+
end
1664+
local cacheKey = displayLevel or 0
1665+
local cache = self.stonefistVariantCache
1666+
if cache then
1667+
local cached = cache[cacheKey]
1668+
if cached ~= nil then
1669+
return cached or nil
1670+
end
1671+
else
1672+
cache = { }
1673+
self.stonefistVariantCache = cache
1674+
end
1675+
local clone = new("Item", self:BuildRaw())
1676+
local newExplicit = { }
1677+
local emitted = { }
1678+
local changed = false
1679+
for _, modLine in ipairs(clone.explicitModLines) do
1680+
local modId = modLine.modId
1681+
if not (modId and map[modId]) then
1682+
modId = clone:ResolveStonefistModId(modLine)
1683+
end
1684+
if modId and map[modId] then
1685+
changed = true
1686+
if not emitted[modId] then
1687+
emitted[modId] = true
1688+
local entry = data.itemMods.FistsOfStone[map[modId]]
1689+
if entry then
1690+
for i = 1, #entry do
1691+
if type(entry[i]) == "string" then
1692+
local text = stonefistAverageRanges(entry[i])
1693+
local list, extra = modLib.parseMod(text)
1694+
t_insert(newExplicit, { line = stonefistComputePerLevel(text, displayLevel), modList = list or { }, extra = extra })
1695+
end
1696+
end
1697+
end
1698+
end
1699+
else
1700+
t_insert(newExplicit, modLine)
1701+
end
1702+
end
1703+
if clone.base and clone.base.armour then
1704+
-- Runeforged/runemastered (Runic Ward) bases use the Runeforged Fists of Stone implicit.
1705+
local hasWard = clone.runicItem or (clone.base.armour.Ward or 0) > 0
1706+
local armourCopy = { }
1707+
for k, v in pairs(clone.base.armour) do
1708+
armourCopy[k] = v
1709+
end
1710+
armourCopy.Armour, armourCopy.Evasion, armourCopy.EnergyShield, armourCopy.Ward = nil, nil, nil, nil
1711+
clone.base = setmetatable({ armour = armourCopy }, { __index = clone.base })
1712+
-- The transformed base has no flat defences; its per-level defence is the base implicit
1713+
-- of the Fists of Stone base (read from the base data so it stays in sync).
1714+
local fistsBase = data.itemBases[hasWard and "Runeforged Fists of Stone" or "Fists of Stone"]
1715+
if fistsBase and fistsBase.implicit then
1716+
local lines = { }
1717+
for implicitLine in (fistsBase.implicit .. "\n"):gmatch("(.-)\n") do
1718+
if implicitLine ~= "" then
1719+
t_insert(lines, implicitLine)
1720+
end
1721+
end
1722+
for i = #lines, 1, -1 do
1723+
local list, extra = modLib.parseMod(lines[i])
1724+
t_insert(clone.implicitModLines, 1, { line = stonefistComputePerLevel(lines[i], displayLevel), modList = list or { }, extra = extra })
1725+
end
1726+
end
1727+
changed = true
1728+
end
1729+
if not changed then
1730+
cache[cacheKey] = false
1731+
return nil
1732+
end
1733+
clone.explicitModLines = newExplicit
1734+
clone:BuildModList()
1735+
clone.baseName = stonefistBaseName
1736+
cache[cacheKey] = clone
1737+
return clone
1738+
end
1739+
15461740
-- Rebuild rune modifiers using the item's runes
15471741
function ItemClass:UpdateRunes()
15481742
wipeTable(self.runeModLines)
@@ -1666,7 +1860,7 @@ function ItemClass:Craft()
16661860
return tonumber(num) + tonumber(other)
16671861
end)
16681862
else
1669-
local modLine = { line = line, order = order }
1863+
local modLine = { line = line, order = order, modId = affix.modId }
16701864
for l = 1, #self.explicitModLines + 1 do
16711865
if not self.explicitModLines[l] or self.explicitModLines[l].order > order then
16721866
t_insert(self.explicitModLines, l, modLine)

0 commit comments

Comments
 (0)