Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
842f860
Runic Ward export and copy paste fix
Blitz54 Jun 8, 2026
e90f275
More Runic Ward renaming
Blitz54 Jun 8, 2026
fd632eb
Fix armour data never printing in blue
Blitz54 Jun 8, 2026
6f5e5f3
Fix Ward scaling with armour, ev, es rune
Blitz54 Jun 9, 2026
6ccec44
More fixes
Blitz54 Jun 9, 2026
8e94926
Add to resource
Blitz54 Jun 10, 2026
1cafe2d
Ward costs
Blitz54 Jun 10, 2026
2e8e102
Some ward on block (doesn't affect EHP yet)
Blitz54 Jun 10, 2026
19b41c7
Runic ward cost efficiency
Blitz54 Jun 10, 2026
cce769c
Runic ward cost warning
Blitz54 Jun 10, 2026
2631c30
Fix ward regen mod
Blitz54 Jun 10, 2026
33a142a
Per maximum runic ward mods
Blitz54 Jun 10, 2026
43eb6e2
Change spirit colour to more creamy yellow like in game, and move spi…
Blitz54 Jun 10, 2026
ac2ad61
Runic Infusion support
Blitz54 Jun 16, 2026
dff5f46
Merge branch 'dev' into runic-ward
Blitz54 Jun 16, 2026
a9110d3
Fix helmet export
Blitz54 Jun 16, 2026
f8dd10d
Low Runic Ward config
Blitz54 Jun 16, 2026
1d77c52
Typo
Blitz54 Jun 16, 2026
3431ee8
Change "Ward" mods to use "RunicWard"
Blitz54 Jun 16, 2026
e289d8f
Runic Ward bypass
Blitz54 Jun 17, 2026
1f128d9
Olroth's Hubris
Blitz54 Jun 17, 2026
9271d94
EHP Calcs, Ward moved to last, Ward on Block works as other gaining s…
Blitz54 Jun 17, 2026
866475f
Fix Ward costs not scaling with other support multis
Blitz54 Jun 17, 2026
32402f4
Revert mod names from RunicWard to Ward
Blitz54 Jun 18, 2026
6963efb
Scouring Flame - and ward cost from life and mana cost
Blitz54 Jun 18, 2026
01bf117
Per runic ward cost - modparser
Blitz54 Jun 18, 2026
67ed146
Add runic ward to resourceTypes - modparser
Blitz54 Jun 18, 2026
67ed754
Fix Runic Ward being used for recovery still
Blitz54 Jun 18, 2026
a799e3e
Rune Of Equinox - mana regen also applies to Ward
Blitz54 Jun 18, 2026
204b7b7
Add missing totalWardDegen
Blitz54 Jun 19, 2026
912f458
Small RunicWard naming change
Blitz54 Jun 20, 2026
dd09dc8
Add Ward to DOT calcs
Blitz54 Jun 20, 2026
be5e3d6
Fix EHP Calcs to leave 1 life, and max hit to leave 1 life
Blitz54 Jun 20, 2026
8322165
Merge branch 'dev' into runic-ward
Blitz54 Jun 30, 2026
99c1b5b
Small fix
Blitz54 Jun 30, 2026
668d265
Remove hitpool fix for tests for now, need to find a cleaner way to f…
Blitz54 Jun 30, 2026
70afbf5
Merge branch 'dev' into runic-ward
Blitz54 Aug 29, 2026
d5b6ab0
Merge branch 'dev' into runic-ward
Blitz54 Aug 29, 2026
603d47e
remove duplicate spirit sidebar
Blitz54 Aug 30, 2026
8a3d4e7
Merge branch 'dev' into runic-ward
Aug 30, 2026
cfa4e75
Fix Runic Ward calculation and other issues
Aug 31, 2026
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
23 changes: 23 additions & 0 deletions spec/System/TestCustomModControl_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,29 @@ describe("Custom modifier controls", function()
end
end)

it("distinguishes missing and empty runic ward conditions", function()
local missingMods, missingExtra = modLib.parseMod("10% increased Attack Speed while missing Runic Ward")
local missingCondition
for _, tag in ipairs(missingMods[1]) do
if tag.type == "Condition" then
missingCondition = tag
end
end
assert.is_nil(missingExtra)
assert.are.equals("MissingRunicWard", missingCondition.var)
assert.is_nil(missingCondition.neg)

local noWardMods, noWardExtra = modLib.parseMod("Lose 5% Life per second while you have no Runic Ward during Effect")
local noWardCondition
for _, tag in ipairs(noWardMods[1]) do
if tag.type == "Condition" and tag.var == "NoRunicWard" then
noWardCondition = tag
end
end
assert.is_nil(noWardExtra)
assert.is_not_nil(noWardCondition)
end)

it("uses the expanded browser dimensions", function()
local popup = openModBrowser()
local listWidth, listHeight = popup.controls.listControl:GetSize()
Expand Down
142 changes: 142 additions & 0 deletions spec/System/TestDefence_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,148 @@ describe("TestDefence", function()
assert.are.equals(manaWithoutTotalEnergyShield + 100, player.output.Mana)
end)

it("applies energy shield modifiers to runic ward when redirected", function()
build.configTab.input.customMods = [[
+100 to maximum Runic Ward
100% increased maximum Runic Ward
100% increased maximum Energy Shield
Increases and Reductions to maximum Energy Shield instead apply to Ward
]]
build.configTab:BuildModList()
runCallback("OnFrame")

assert.are.equals(300, build.calcsTab.mainOutput.Ward)
end)

it("does not apply energy shield more modifiers to redirected runic ward", function()
build.configTab.input.customMods = [[
+100 to maximum Runic Ward
100% more maximum Energy Shield
Increases and Reductions to maximum Energy Shield instead apply to Ward
]]
build.configTab:BuildModList()
runCallback("OnFrame")

assert.are.equals(100, build.calcsTab.mainOutput.Ward)
end)

it("applies mana regeneration to runic ward before mana degeneration", function()
build.configTab.input.customMods = "+100 to maximum Runic Ward\nLose 5% of maximum Mana per Second"
build.configTab:BuildModList()
build.itemsTab:CreateDisplayItemFromRaw([[
Test Wand
Runic Fork
Sockets: S
Rune: Warding Rune of Equinox
Implicits: 3
{enchant}{rune}40% less Mana Regeneration Rate
{enchant}{rune}Mana Recovery from Regeneration is also applied to Runic Ward
{enchant}{rune}Bonded: 20% increased Runic Ward Regeneration Rate if you've dealt a Critical Hit Recently
]])
build.itemsTab:AddDisplayItem()
runCallback("OnFrame")

local output = build.calcsTab.mainOutput
assert.is_true(output.ManaDegen > 0)
assert.are.near(output.ManaRegen, output.WardRecovery, 0.1)
assert.are.near(output.ManaRegen - output.ManaDegen, output.ManaRegenRecovery, 0.1)
end)

it("applies life flask recovery to runic ward through Warding Rune of Nourishment", function()
build.skillsTab:PasteSocketGroup("Ball Lightning 1/0 1")
build.itemsTab:CreateDisplayItemFromRaw([[
Test Gloves
Runeforged Stocky Mitts
Sockets: S
Rune: Warding Rune of Nourishment
Implicits: 2
{enchant}{rune}15% Life Recovery from Flasks also applies to Runic Ward
{enchant}{rune}Bonded: 15% increased Life Recovery from Flasks
]])
build.itemsTab:AddDisplayItem()
build.itemsTab:CreateDisplayItemFromRaw([[
Test Flask
Ultimate Life Flask
Life Flask Effects are not removed when Unreserved Life is Filled
]])
build.itemsTab:AddDisplayItem()
build.itemsTab:EquipItemInSet(build.itemsTab.items[2], build.itemsTab.activeItemSetId)
build.itemsTab.slots["Flask 1"].active = true
runCallback("OnFrame")

local output = build.calcsTab.calcsOutput
assert.are.equals(15, build.calcsTab.calcsEnv.player.modDB:Sum("BASE", nil, "LifeFlaskRecoveryAppliesToWard"))
assert.is_true(output.LifeRecovery > 0)
assert.is_true(output.WardRecovery > 0)
assert.are.near(output.LifeRecovery * 0.15, output.WardRecovery, 0.01)
end)

it("includes runic ward in comprehensive net recovery", function()
build.configTab.input.customMods = "+100 to maximum Runic Ward"
build.configTab.input.enemyDamageType = "DamageOverTime"
build.configTab.input.enemyFireDamage = 100
build.configTab:BuildModList()
runCallback("OnFrame")

local output = build.calcsTab.calcsOutput
assert.are.equals(output.ComprehensiveNetLifeRegen + output.ComprehensiveNetManaRegen + output.ComprehensiveNetWardRegen + output.ComprehensiveNetEnergyShieldRegen, output.ComprehensiveTotalNetRegen)
end)

it("does not count fully bypassed runic ward as a hit pool", function()
build.configTab.input.customMods = "All damage taken bypasses Runic Ward"
build.configTab:BuildModList()
runCallback("OnFrame")
local withoutWard = build.calcsTab.calcsOutput.FireTotalHitPool

build.configTab.input.customMods = "+100 to maximum Runic Ward\nAll damage taken bypasses Runic Ward"
build.configTab:BuildModList()
runCallback("OnFrame")

local output = build.calcsTab.calcsOutput
assert.are.equals(withoutWard, output.FireTotalHitPool)
assert.is_true(output.NumberOfDamagingHits < data.misc.ehpCalcMaxIterationsToCalc)
end)

it("adds non-bypassed runic ward to damage over time pool", function()
build.configTab.input.customMods = ""
build.configTab.input.enemyDamageType = "DamageOverTime"
build.configTab.input.enemyFireDamage = 100
build.configTab:BuildModList()
runCallback("OnFrame")
local withoutWard = build.calcsTab.calcsOutput.FireTotalPool

build.configTab.input.customMods = "+100 to maximum Runic Ward"
build.configTab:BuildModList()
runCallback("OnFrame")

assert.are.equals(withoutWard + 100, build.calcsTab.calcsOutput.FireTotalPool)

build.configTab.input.customMods = "+100 to maximum Runic Ward\nAll damage taken bypasses Runic Ward"
build.configTab:BuildModList()
runCallback("OnFrame")

assert.are.equals(withoutWard, build.calcsTab.calcsOutput.FireTotalPool)
end)

it("includes remaining runic ward in hit pool tracking", function()
build.configTab.input.customMods = "+100 to maximum Runic Ward"
build.configTab:BuildModList()
runCallback("OnFrame")

local output = build.calcsTab.calcsOutput
local poolsRemaining = build.calcsTab.calcs.reducePoolsByDamage(nil, { Physical = 1 }, build.calcsTab.calcsEnv.player)

assert.are.near(output.PhysicalTotalHitPool - 1, poolsRemaining.hitPoolRemaining, 0.01)

build.configTab.input.customMods = "+100 to maximum Runic Ward\nAll damage taken bypasses Runic Ward"
build.configTab:BuildModList()
runCallback("OnFrame")

output = build.calcsTab.calcsOutput
poolsRemaining = build.calcsTab.calcs.reducePoolsByDamage(nil, { Physical = 1 }, build.calcsTab.calcsEnv.player)
assert.are.near(output.PhysicalTotalHitPool - 1, poolsRemaining.hitPoolRemaining, 0.01)
end)

it("no armour max hits", function()
build.configTab.input.enemyIsBoss = "None"
build.configTab.input.customMods = ""
Expand Down
15 changes: 15 additions & 0 deletions spec/System/TestImportTab_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ describe("ImportTab", function()
assert.True(importedSpec.nodes[templarStartPassive.id].alloc)
assert.True(importedSpec.nodes[templarStartPassive.id].connectedToStart)
end)

it("imports the Runic Ward item property", function()
build.importTab:ImportItem({
id = "runic-ward-test",
frameType = 0,
name = "",
typeLine = "Runeforged Sentinel Greathelm",
inventoryId = "Helm",
ilvl = 52,
properties = { { name = "[Ward|Runic Ward]", values = { { "180", 0 } } } },
})

local item = build.itemsTab.items[build.itemsTab.slots["Helmet"].selItemId]
assert.is_truthy(item.raw:match("Runic Ward: 180"))
end)
end)

describe("ImportTab BuildPlanner export option", function()
Expand Down
6 changes: 6 additions & 0 deletions spec/System/TestItemMods_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ describe("TetsItemMods", function()
assert.is_nil(mod.weightMultiplierKey)
end)

it("does not colour a stat as modified without a base value", function()
assert.are.equals("^7", main:StatColor(10, nil, 90))
assert.are.equals(colorCodes.MAGIC, main:StatColor(10, 5, 90))
assert.are.equals(colorCodes.NEGATIVE, main:StatColor(100, nil, 90))
end)

it("shows duplicate selected variants in item tooltips when enabled", function()
local item = new("Item"):Item([[
Rarity: Unique
Expand Down
7 changes: 7 additions & 0 deletions spec/System/TestItemParse_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ describe("TestItemParse", function()
-- assert.are.equals(180, item.armourData.Ward)
--end)

it("Ward defence", function()
local item = new("Item"):Item(raw("Ward: 180", "Runic Crown"))
assert.are.equals(180, item.armourData.Ward)
item = new("Item"):Item(raw("Runic Ward: 180", "Runic Crown"))
assert.are.equals(180, item.armourData.Ward)
end)

it("Title", function()
local item = new("Item"):Item([[
Rarity: Rare
Expand Down
41 changes: 41 additions & 0 deletions spec/System/TestSkills_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,47 @@ describe("TestSkills", function()
assertGemSupportLevel("Apocalypse", 3, 4)
end)

it("applies Leylines Runic Ward degeneration", function()
build.skillsTab:PasteSocketGroup("Leylines 1/0 1")
build.configTab.input.onLeyline = true
build.configTab:BuildModList()
runCallback("OnFrame")

local output = build.calcsTab.mainOutput
assert.are.near(206 / 60, output.WardDegen, 0.01)
assert.are.near(output.WardRegen - output.WardDegen, output.WardRegenRecovery, 0.01)
end)

it("calculates Scouring Flame runic ward cost and efficiency", function()
build.skillsTab:PasteSocketGroup("Ball Lightning 1/0 1\nScouring Flame 1/0 1")
runCallback("OnFrame")
assert.are.equals(2, build.calcsTab.mainOutput.WardCost)

build.configTab.input.customMods = "100% increased Runic Ward Cost Efficiency"
build.configTab:BuildModList()
runCallback("OnFrame")
assert.are.equals(1, build.calcsTab.mainOutput.WardCost)
end)

it("calculates runic infusion support costs from maximum runic ward", function()
for _, support in ipairs({ "Runic Infusion", "Olroth's Hubris" }) do
newBuild()
build.configTab.input.customMods = "+100 to maximum Runic Ward"
build.configTab:BuildModList()
build.itemsTab:CreateDisplayItemFromRaw("New Item\nMarauding Mace")
build.itemsTab:AddDisplayItem()
build.skillsTab:PasteSocketGroup("Leap Slam 1/0 1\n" .. support .. " 1/0 1")
runCallback("OnFrame")
assert.are.equals(20, build.calcsTab.mainOutput.WardCost)
end
end)

it("calculates Runic Reprieve's ongoing runic ward cost", function()
build.skillsTab:PasteSocketGroup("Runic Reprieve 1/0 1")
runCallback("OnFrame")
assert.are.equals(3, build.calcsTab.mainOutput.WardPerSecondCost)
end)

it("applies Advanced Thaumaturgy quality stats only when enabled", function()
local advancedThaumaturgy = build.spec.nodes[14429]
assert.is_not_nil(advancedThaumaturgy)
Expand Down
4 changes: 2 additions & 2 deletions src/Classes/CompareBuySimilar.lua
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ function M.openPopup(item, slotName, primaryBuild)
{ key = "Armour", label = "Armour", tradeKey = "ar" },
{ key = "Evasion", label = "Evasion", tradeKey = "ev" },
{ key = "EnergyShield", label = "Energy Shield", tradeKey = "es" },
{ key = "Ward", label = "Ward", tradeKey = "ward" },
{ key = "Ward", label = "Runic Ward", tradeKey = "ward" },
}
for _, def in ipairs(defences) do
local val = item.armourData[def.key]
Expand Down Expand Up @@ -497,7 +497,7 @@ function M.openPopup(item, slotName, primaryBuild)
end
displayTexts[index] = displayText
end


local displayText = table.concat(displayTexts, "\n")
-- labels anchor based on the first row instead of the middle row, so adjust upwards
Expand Down
2 changes: 1 addition & 1 deletion src/Classes/CompareTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3554,7 +3554,7 @@ function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap
drawY = drawY + lineHeight
end
if ward > 0 then
DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FWard: " .. colorCodes.MAGIC .. "%d", ward))
DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FRunic Ward: " .. colorCodes.MAGIC .. "%d", ward))
drawY = drawY + lineHeight
end
if armourData.BlockChance and armourData.BlockChance > 0 then
Expand Down
16 changes: 9 additions & 7 deletions src/Classes/ImportTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1419,29 +1419,31 @@ function ImportTabClass:ImportItem(itemData, slotName)
end
if itemData.properties then
for _, property in pairs(itemData.properties) do
if escapeGGGString(property.name) == "Quality" then
local propertyName = escapeGGGString(property.name)
if propertyName == "Quality" then
item.quality = tonumber(property.values[1][1]:match("%d+"))
elseif property.name == "Radius" then
elseif propertyName == "Radius" then
item.jewelRadiusLabel = property.values[1][1]
elseif property.name == "Limited to" then
elseif propertyName == "Limited to" then
item.limit = tonumber(property.values[1][1])
elseif property.name == "Evasion Rating" then
elseif propertyName == "Evasion Rating" then
if item.baseName == "Two-Toned Boots (Armour/Energy Shield)" then
-- Another hack for Two-Toned Boots
item.baseName = "Two-Toned Boots (Armour/Evasion)"
item.base = self.build.data.itemBases[item.baseName]
end
elseif property.name == "Energy Shield" then
elseif propertyName == "Energy Shield" then
if item.baseName == "Two-Toned Boots (Armour/Evasion)" then
-- Yet another hack for Two-Toned Boots
item.baseName = "Two-Toned Boots (Evasion/Energy Shield)"
item.base = self.build.data.itemBases[item.baseName]
end
end
if property.name == "Energy Shield" or property.name == "Ward" or property.name == "Armour" or property.name == "Evasion Rating" then
if propertyName == "Energy Shield" or propertyName == "Runic Ward" or propertyName == "Armour" or propertyName == "Evasion Rating" then
item.armourData = item.armourData or { }
local defenceType = propertyName:gsub("Runic Ward", "Ward"):gsub(" Rating", ""):gsub(" ", "")
for _, value in ipairs(property.values) do
item.armourData[property.name:gsub(" Rating", ""):gsub(" ", "")] = (item.armourData[property.name:gsub(" Rating", ""):gsub(" ", "")] or 0) + tonumber(value[1])
item.armourData[defenceType] = (item.armourData[defenceType] or 0) + tonumber(value[1])
end
end
end
Expand Down
6 changes: 4 additions & 2 deletions src/Classes/Item.lua
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
-- Parsed before item lines so tagged bases and modifiers see the final selection.
elseif specName == "Talisman Tier" then
self.talismanTier = specToNumber(specVal)
elseif specName == "Armour" or specName == "Evasion Rating" or specName == "Evasion" or specName == "Energy Shield" or specName == "Ward" then
elseif specName == "Armour" or specName == "Evasion Rating" or specName == "Evasion" or specName == "Energy Shield" or specName == "Ward" or specName == "Runic Ward" then
if specName == "Evasion Rating" then
specName = "Evasion"
if self.baseName == "Two-Toned Boots (Armour/Energy Shield)" then
Expand All @@ -847,6 +847,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality)
self.baseName = "Two-Toned Boots (Evasion/Energy Shield)"
self.base = data.itemBases[self.baseName]
end
elseif specName == "Runic Ward" then
specName = "Ward"
end
self.armourData = self.armourData or { }
self.armourData[specName] = specToNumber(specVal)
Expand Down Expand Up @@ -1841,7 +1843,7 @@ function ItemClass:BuildRaw()
if self.armourData then
for _, type in ipairs({ "Armour", "Evasion", "EnergyShield", "Ward" }) do
if self.armourData[type] and self.armourData[type] > 0 then
t_insert(rawLines, type:gsub("EnergyShield", "Energy Shield") .. ": " .. self.armourData[type])
t_insert(rawLines, type:gsub("EnergyShield", "Energy Shield"):gsub("Ward", "Runic Ward") .. ": " .. self.armourData[type])
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions src/Data/Costs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ return {
[5] = {
Resource = "Ward",
Stat = "base_ward_cost",
ResourceString = "{0} Ward",
ResourceString = "{0} Runic Ward",
Divisor = 1,
},
[6] = {
Expand All @@ -47,7 +47,7 @@ return {
[8] = {
Resource = "WardPercent",
Stat = "base_ward_cost_%",
ResourceString = "{0}% Ward",
ResourceString = "{0}% Runic Ward",
Divisor = 1,
},
[9] = {
Expand Down Expand Up @@ -107,7 +107,7 @@ return {
[18] = {
Resource = "WardPerMinute",
Stat = "base_ward_cost_per_minute",
ResourceString = "{0} Ward per second",
ResourceString = "{0} Runic Ward per second",
Divisor = 60,
},
[19] = {
Expand Down
Loading
Loading