Skip to content

Commit 3021f58

Browse files
committed
fix: correct Runic Ward import, double-count, and EHP operator precedence
Three bugs in the Runic Ward implementation: 1. ImportTab.ImportItem missing return statement ImportItem built and wired the item correctly but never returned it, so all callers received nil. Fixed by adding `return item` at the end. 2. Ward double-counting when property line is authoritative When armourData.Ward is pre-set from a pasted or API-imported property line ("Runic Ward: 104"), that value is the game's final post-quality result. The old code still ran calcLocal for INC rune mods on top of the authoritative value, inflating Ward and leaking unconsumed BASE mods to CalcDefence. Introduced wardIsAuthoritative flag: in that path both BASE and INC local mods are consumed (removed from modList) but their values are discarded; the authoritative value is used verbatim. 3. Operator precedence zeroing EHP pool `output[...TotalHitPool] + output.Ward or 0` evaluated as `(... + output.Ward) or 0`, which zeroed the entire pool when Ward was nil. Fixed with explicit parentheses: `(output.Ward or 0)`. All 392 tests pass.
1 parent a2ba265 commit 3021f58

6 files changed

Lines changed: 139 additions & 29 deletions

File tree

spec/System/TestImportTab_spec.lua

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,57 @@ describe("ImportTab", function()
33
newBuild()
44
end)
55

6+
it("parses [Ward|Runic Ward] property from GGG API data and sets armourData.Ward (import path)", function()
7+
local importTab = build.importTab
8+
-- Simulate a GGG API item with localized Ward property name
9+
local mockItemData = {
10+
typeLine = "Runeforged Serpentscale Coat",
11+
name = "Empyrean Shelter",
12+
frameType = 2,
13+
inventoryId = "BodyArmour",
14+
id = "body1",
15+
ilvl = 36,
16+
mirrored = false,
17+
corrupted = false,
18+
properties = {
19+
{ name = "[Ward|Runic Ward]", values = {{"104", 0}}, type = 104 },
20+
},
21+
}
22+
-- Returns (item, slotName)
23+
local item = importTab:ImportItem(mockItemData, "Body Armour")
24+
assert.is_not_nil(item)
25+
assert.are.equals(104, item.armourData.Ward)
26+
end)
27+
28+
it("parses [Ward|Runic Ward] property with a Ward rune mod without double-counting", function()
29+
local importTab = build.importTab
30+
-- GGG API item data that includes rune mods adding Ward
31+
local mockItemData = {
32+
typeLine = "Runeforged Itinerant Jacket",
33+
name = "Loath Coat",
34+
frameType = 2,
35+
inventoryId = "BodyArmour",
36+
id = "body2",
37+
ilvl = 67,
38+
mirrored = false,
39+
corrupted = false,
40+
properties = {
41+
{ name = "[Ward|Runic Ward]", values = {{"104", 0}}, type = 104 },
42+
{ name = "[Evasion|Evasion Rating]", values = {{"157", 0}}, type = 4 },
43+
{ name = "[EnergyShield|Energy Shield]", values = {{"61", 0}}, type = 6 },
44+
},
45+
runeMods = {
46+
"{rune}{enchant}112% increased Ward\n+65 to maximum Ward",
47+
},
48+
}
49+
local item = importTab:ImportItem(mockItemData, "Body Armour")
50+
assert.is_not_nil(item)
51+
-- Property line value is authoritative for Ward; rune INC/flat mods must not be re-applied
52+
assert.are.equals(104, item.armourData.Ward)
53+
-- Rune mods should be parsed into runeModLines
54+
assert.are.equals(2, #item.runeModLines)
55+
end)
56+
657
it("builds character lists for private Ruthless league names without a Ruthless tree", function()
758
local importTab = build.importTab
859
importTab.lastCharList = {

spec/System/TestItemParse_spec.lua

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ describe("TestItemParse", function()
461461
assert.are.equals(6, item.jewelSocketCount)
462462
end)
463463

464-
it("Runeforged prefix is stripped from base name", function()
464+
it("Runeforged prefix is stripped from base name and item parses Runic Ward", function()
465465
local item = new("Item", [[
466466
Rarity: Rare
467467
Empyrean Shelter
@@ -473,6 +473,33 @@ describe("TestItemParse", function()
473473
Item Level: 36
474474
]])
475475
assert.are.equals("Serpentscale Coat", item.baseName)
476+
assert.are.equals(83, item.armourData.Ward)
477+
end)
478+
479+
it("Has +N to Evasion Rating per player level parses to BASE Evasion with Level multiplier", function()
480+
local item = new("Item", [[
481+
Rarity: Rare
482+
Striker's Grip
483+
Fists of Stone
484+
--------
485+
Implicits: 3
486+
Has +2 to Evasion Rating per player level (implicit)
487+
Has +1 to maximum Energy Shield per player level (implicit)
488+
Has +1 to maximum Runic Ward per player level (implicit)
489+
]])
490+
-- All three implicit mod lines should be parsed (no extra/unsupported flag)
491+
assert.are.equals(3, #item.implicitModLines)
492+
assert.is_nil(item.implicitModLines[1].extra)
493+
assert.is_nil(item.implicitModLines[2].extra)
494+
assert.is_nil(item.implicitModLines[3].extra)
495+
-- Check raw mod values
496+
assert.are.equals(2, item.baseModList[1].value)
497+
assert.are.equals(1, item.baseModList[2].value)
498+
assert.are.equals(1, item.baseModList[3].value)
499+
-- Check mod names map to correct stats
500+
assert.are.equals("Evasion", item.baseModList[1].name)
501+
assert.are.equals("EnergyShield", item.baseModList[2].name)
502+
assert.are.equals("Ward", item.baseModList[3].name)
476503
end)
477504
end)
478505

@@ -665,29 +692,21 @@ describe("TestAdvancedItemParse #item", function()
665692
]])
666693
end)
667694

668-
it("Has +N to Evasion Rating per player level parses to BASE Evasion with Level multiplier", function()
695+
it("Runeforged item Ward survives BuildModList recalculation (paste path)", function()
669696
local item = new("Item", [[
670697
Rarity: Rare
671-
Striker's Grip
672-
Fists of Stone
698+
Empyrean Shelter
699+
Runeforged Serpentscale Coat
673700
--------
674-
Implicits: 3
675-
Has +2 to Evasion Rating per player level (implicit)
676-
Has +1 to maximum Energy Shield per player level (implicit)
677-
Has +1 to maximum Runic Ward per player level (implicit)
701+
Evasion Rating: 543
702+
Runic Ward: 83
703+
--------
704+
Item Level: 36
678705
]])
679-
-- All three implicit mod lines should be parsed (no extra/unsupported flag)
680-
assert.are.equals(3, #item.implicitModLines)
681-
assert.is_nil(item.implicitModLines[1].extra)
682-
assert.is_nil(item.implicitModLines[2].extra)
683-
assert.is_nil(item.implicitModLines[3].extra)
684-
-- Check raw mod values
685-
assert.are.equals(2, item.baseModList[1].value)
686-
assert.are.equals(1, item.baseModList[2].value)
687-
assert.are.equals(1, item.baseModList[3].value)
688-
-- Check mod names map to correct stats
689-
assert.are.equals("Evasion", item.baseModList[1].name)
690-
assert.are.equals("EnergyShield", item.baseModList[2].name)
691-
assert.are.equals("Ward", item.baseModList[3].name)
706+
assert.are.equals(83, item.armourData.Ward)
707+
-- BuildModList must preserve the property line value verbatim.
708+
-- "Runic Ward: 83" is the game's final post-quality value; PoB must NOT re-apply quality.
709+
item:BuildModList()
710+
assert.are.equals(83, item.armourData.Ward)
692711
end)
693712
end)

spec/System/TestWard_spec.lua

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,29 @@ describe("TestWard", function()
171171
assert.are.equals(225, build.calcsTab.calcsOutput.Ward)
172172
end)
173173

174+
it("Runeforged Ward from item with rune mods does not double-count (import path regression)", function()
175+
-- Create an item through the build (paste a Runeforged item with rune-like Ward mod)
176+
local item = new("Item", [[
177+
Rarity: Rare
178+
Mock Runeforged Coat
179+
Runeforged Serpentscale Coat
180+
--------
181+
Runic Ward: 104
182+
--------
183+
Item Level: 67
184+
--------
185+
+65 to maximum Ward
186+
]])
187+
-- The game displays the FINAL ward value in the property line (post-quality, post-all-mods).
188+
-- "Runic Ward: 104" means 104 is the game-computed final value.
189+
-- PoB must NOT re-apply quality or add the +65 mod on top of the authoritative value.
190+
-- Without fix: Ward = round((104+65) * 1.2) = 203 (double-counts flat mod and quality)
191+
-- With fix (property line authoritative): Ward = 104 (no re-scaling)
192+
item:BuildModList()
193+
-- 104 = property line verbatim; the +65 mod is consumed (not re-applied) and quality is already baked in
194+
assert.are.equals(104, item.armourData.Ward)
195+
end)
196+
174197
it("WardCoverOnMinionDeath stat ID parses correctly", function()
175198
build.configTab.input.customMods = "\z
176199
recover 10% of maximum ward on persistent minion death\n\z

src/Classes/ImportTab.lua

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,22 +1101,23 @@ function ImportTabClass:ImportItem(itemData, slotName)
11011101
item.jewelRadiusLabel = property.values[1][1]
11021102
elseif property.name == "Limited to" then
11031103
item.limit = tonumber(property.values[1][1])
1104-
elseif property.name == "Evasion Rating" then
1104+
elseif escapeGGGString(property.name) == "Evasion Rating" then
11051105
if item.baseName == "Two-Toned Boots (Armour/Energy Shield)" then
11061106
-- Another hack for Two-Toned Boots
11071107
item.baseName = "Two-Toned Boots (Armour/Evasion)"
11081108
item.base = self.build.data.itemBases[item.baseName]
11091109
end
1110-
elseif property.name == "Energy Shield" then
1110+
elseif escapeGGGString(property.name) == "Energy Shield" then
11111111
if item.baseName == "Two-Toned Boots (Armour/Evasion)" then
11121112
-- Yet another hack for Two-Toned Boots
11131113
item.baseName = "Two-Toned Boots (Evasion/Energy Shield)"
11141114
item.base = self.build.data.itemBases[item.baseName]
11151115
end
11161116
end
1117-
if property.name == "Energy Shield" or property.name == "Ward" or property.name == "Runic Ward" or property.name == "Armour" or property.name == "Evasion Rating" then
1117+
local escapedPropertyName = escapeGGGString(property.name)
1118+
if escapedPropertyName == "Energy Shield" or escapedPropertyName == "Ward" or escapedPropertyName == "Runic Ward" or escapedPropertyName == "Armour" or escapedPropertyName == "Evasion Rating" then
11181119
item.armourData = item.armourData or { }
1119-
local armourKey = property.name:gsub(" Rating", ""):gsub(" ", "")
1120+
local armourKey = escapedPropertyName:gsub(" Rating", ""):gsub(" ", "")
11201121
if armourKey == "RunicWard" then armourKey = "Ward" end
11211122
for _, value in ipairs(property.values) do
11221123
item.armourData[armourKey] = (item.armourData[armourKey] or 0) + tonumber(value[1])
@@ -1288,6 +1289,7 @@ function ImportTabClass:ImportItem(itemData, slotName)
12881289
ConPrintf("Unrecognised slot name in imported item: %s", slotName)
12891290
end
12901291
end
1292+
return item
12911293
end
12921294

12931295
function ImportTabClass:ImportSocketedItems(item, socketedItems, slotName)

src/Classes/Item.lua

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,13 +1778,26 @@ function ItemClass:BuildModListForSlotNum(baseList, slotNum)
17781778
local evasionEnergyShieldBase = calcLocal(modList, "EvasionAndEnergyShield", "BASE", 0)
17791779
local energyShieldBase = calcLocal(modList, "EnergyShield", "BASE", 0) + (self.base.armour.EnergyShield or 0)
17801780
local armourEnergyShieldBase = calcLocal(modList, "ArmourAndEnergyShield", "BASE", 0)
1781-
local wardBase = calcLocal(modList, "Ward", "BASE", 0) + (self.base.armour.Ward or 0)
1781+
-- wardIsAuthoritative: true when armourData.Ward was pre-set from a game property line
1782+
-- (paste or API import). That value is the final game-computed ward (already includes flat
1783+
-- rune mods, INC rune mods, and quality). We must consume the local ward mods from modList
1784+
-- to prevent them from being double-applied in CalcDefence, but we must NOT re-scale the
1785+
-- already-final value by wardInc, defencesInc, or qualityScalar.
1786+
local wardIsAuthoritative = armourData.Ward ~= nil
1787+
local wardBase
1788+
if wardIsAuthoritative then
1789+
calcLocal(modList, "Ward", "BASE", 0) -- consume flat rune ward mods (discard result)
1790+
calcLocal(modList, "Ward", "INC", 0) -- consume INC rune ward mods (discard result)
1791+
wardBase = armourData.Ward + (self.base.armour.Ward or 0)
1792+
else
1793+
wardBase = calcLocal(modList, "Ward", "BASE", 0) + (self.base.armour.Ward or 0)
1794+
end
17821795
local armourInc = calcLocal(modList, "Armour", "INC", 0)
17831796
local armourEvasionInc = calcLocal(modList, "ArmourAndEvasion", "INC", 0)
17841797
local evasionInc = calcLocal(modList, "Evasion", "INC", 0)
17851798
local evasionEnergyShieldInc = calcLocal(modList, "EvasionAndEnergyShield", "INC", 0)
17861799
local energyShieldInc = calcLocal(modList, "EnergyShield", "INC", 0)
1787-
local wardInc = calcLocal(modList, "Ward", "INC", 0)
1800+
local wardInc = wardIsAuthoritative and 0 or calcLocal(modList, "Ward", "INC", 0)
17881801
local armourEnergyShieldInc = calcLocal(modList, "ArmourAndEnergyShield", "INC", 0)
17891802
local defencesInc = calcLocal(modList, "Defences", "INC", 0)
17901803
local qualityScalar = self.quality
@@ -1795,7 +1808,9 @@ function ItemClass:BuildModListForSlotNum(baseList, slotNum)
17951808
armourData.Armour = round((armourBase + armourEvasionBase + armourEnergyShieldBase) * (1 + (armourInc + armourEvasionInc + armourEnergyShieldInc + defencesInc) / 100) * (1 + (qualityScalar / 100)))
17961809
armourData.Evasion = round((evasionBase + armourEvasionBase + evasionEnergyShieldBase) * (1 + (evasionInc + armourEvasionInc + evasionEnergyShieldInc + defencesInc) / 100) * (1 + (qualityScalar / 100)))
17971810
armourData.EnergyShield = round((energyShieldBase + evasionEnergyShieldBase + armourEnergyShieldBase) * (1 + (energyShieldInc + armourEnergyShieldInc + evasionEnergyShieldInc + defencesInc) / 100) * (1 + (qualityScalar / 100)))
1798-
armourData.Ward = round((wardBase) * (1 + (wardInc + defencesInc) / 100) * (1 + (qualityScalar / 100)))
1811+
armourData.Ward = wardIsAuthoritative
1812+
and round(wardBase) -- property line is final (game already applied quality+INC); do not re-scale
1813+
or round((wardBase) * (1 + (wardInc + defencesInc) / 100) * (1 + (qualityScalar / 100)))
17991814

18001815
if self.base.armour.BlockChance then
18011816
armourData.BlockChance = m_floor((self.base.armour.BlockChance * (1 + calcLocal(modList, "BlockChance", "INC", 0) / 100) + calcLocal(modList, "BlockChance", "BASE", 0)))

src/Modules/CalcDefence.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3631,7 +3631,7 @@ function calcs.buildDefenceEstimations(env, actor)
36313631
sourcePool = m_max(sourcePool - poolProtected, 0) + m_min(sourcePool, poolProtected) / (wardBypass / 100)
36323632
output[damageType.."TotalHitPool"] = sourcePool
36333633
else
3634-
output[damageType.."TotalHitPool"] = output[damageType.."TotalHitPool"] + output.Ward or 0
3634+
output[damageType.."TotalHitPool"] = output[damageType.."TotalHitPool"] + (output.Ward or 0)
36353635
end
36363636
-- aegis
36373637
output[damageType.."TotalHitPool"] = output[damageType.."TotalHitPool"] + m_max(m_max(output[damageType.."Aegis"], output["sharedAegis"]), isElemental[damageType] and output[damageType.."AegisDisplay"] or 0)

0 commit comments

Comments
 (0)