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
162 changes: 162 additions & 0 deletions spec/System/TestAttacks_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,166 @@ describe("TestAttacks", function()
assert.is_true(math.abs(bifurcateChance - build.calcsTab.mainOutput.MainHand.CritBifurcates) < 0.000001)
assert.are.equals(1 + critBonusMultiplier, build.calcsTab.mainOutput.MainHand.AverageHit)
end)

-- Dual Wield tests
local setupDualWieldTestConditions = function()
local slowHighDmgMace = [[
Slow High Crit High Damage Mace
Marauding Mace
Quality: 0
200% increased physical damage
100% increased critical hit chance
-25% increased attack speed
]]

local fastLowDmgMace = [[
Fast Low Crit Low Damage Mace
Marauding Mace
Quality: 0
-50% increased physical damage
-100% increased critical hit chance
50% increased attack speed
]]

build.itemsTab:CreateDisplayItemFromRaw(slowHighDmgMace)
build.itemsTab:AddDisplayItem()
runCallback("OnFrame")
build.itemsTab.slots["Weapon 1"]:SetSelItemId(build.itemsTab.items[1].id)

build.itemsTab:CreateDisplayItemFromRaw(fastLowDmgMace)
build.itemsTab:AddDisplayItem()
runCallback("OnFrame")
build.itemsTab.slots["Weapon 2"]:SetSelItemId(build.itemsTab.items[2].id)

build.configTab.input.customMods = [[
nearby enemies have 100% less armour
your hits can't be evaded
]]
build.configTab:BuildModList()
runCallback("OnFrame")
end

local function harmonicMean(a, b)
return 2 / (1/a + 1/b)
end

it("correctly calculates dual wield DPS for double hits", function()
setupDualWieldTestConditions()
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1")
runCallback("OnFrame")
build.calcsTab:BuildOutput()
runCallback("OnFrame")

-- Attack Speed
local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed
local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed
local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed)
assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4))

-- Average Hit
local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage
local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage
local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage
assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg) / 2, 4), round(combinedAvgDmg, 4))

-- DPS (hits twice per attack)
local combinedDPS = build.calcsTab.mainOutput.TotalDPS
assert.are.equals(round(combinedAvgDmg * combinedSpeed * 2,4), round(combinedDPS,4))
end)

it("correctly calculates dual wield crit chance for double hits", function()
setupDualWieldTestConditions()
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1")
runCallback("OnFrame")
build.calcsTab:BuildOutput()
runCallback("OnFrame")

-- Double hits roll crit individually per weapon, so should be average
local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance
local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance
local combinedCritChance = (mainHandCritChance + offHandCritChance) / 2
assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance)
end)

it("correctly calculates dual wield DPS for alternating hits", function()
setupDualWieldTestConditions()
build.skillsTab:PasteSocketGroup("Armour Breaker 20/0 1")
runCallback("OnFrame")
build.calcsTab:BuildOutput()
runCallback("OnFrame")

-- Attack Speed
local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed
local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed
local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed)
assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4))

-- Average Hit
local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage
local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage
local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage
assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg) / 2, 4), round(combinedAvgDmg, 4))

-- DPS (hits once per attack)
local combinedDPS = build.calcsTab.mainOutput.TotalDPS
assert.are.equals(round(combinedAvgDmg * combinedSpeed, 4), round(combinedDPS, 4))
end)

it("correctly calculates dual wield crit chance for alternating hits", function()
setupDualWieldTestConditions()
build.skillsTab:PasteSocketGroup("Armour Breaker 20/0 1")
runCallback("OnFrame")
build.calcsTab:BuildOutput()
runCallback("OnFrame")

-- Alternating hits roll crit individually per weapon, so should be average
local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance
local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance
local combinedCritChance = (mainHandCritChance + offHandCritChance) / 2
assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance)
end)

--[[
NOTE: the following section contains tests for "combined hits", which PoE2 doesn't have as of 2026-06-02,
which means the tests were written for a temporary test skill that will not be committed.
The test can be updated by simply replacing `"skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1"`
with actual skill data once available
]]
--[[ it("correctly calculates dual wield DPS for combined hits", function()
setupDualWieldTestConditions()
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1")
runCallback("OnFrame")
build.calcsTab:BuildOutput()
runCallback("OnFrame")

-- Attack Speed
local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed
local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed
local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed)
assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4))

-- Average Hit
local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage
local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage
local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage
assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg), 4), round(combinedAvgDmg, 4))

-- DPS (hits twice per attack)
local combinedDPS = build.calcsTab.mainOutput.TotalDPS
assert.are.equals(round(combinedAvgDmg * combinedSpeed, 4), round(combinedDPS,4))
end)

it("correctly calculates dual wield crit chance for combined hits", function()
setupDualWieldTestConditions()
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1")
runCallback("OnFrame")
build.calcsTab:BuildOutput()
runCallback("OnFrame")

-- combined hits count whole attack as crit, as long as one hand rolls crit)
local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance
local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance
local combinedCritChance = mainHandCritChance + offHandCritChance - (mainHandCritChance * offHandCritChance / 100)
assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance)
end) ]]
end)
33 changes: 31 additions & 2 deletions src/Classes/SkillsTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,21 @@ function SkillsTabClass:CopySocketGroup(socketGroup)
Copy(skillText)
end

--- Parses pasted socketGroup or custom test string to generate new socketGroup
--- @param testInput string? optional string input mainly used for tests
---
--- **Expected `testInput` Format:**
--- ```text
--- [Label: <Group Label>] (Optional)
--- [Slot: <Equipment Slot>] (Optional)
--- [skillId:<id>] <Skill Name> <Level>/<Quality> [STATE] <Count> [C] [+/-CorruptLevel]
--- ```
--- `skillId` is only needed if skill name is not unique
--- `STATE` only used to disable gem via `DISABLED`
---
--- **Example lines:**
--- * `"skillId:LightningSpearPlayer Lightning Spear 20/7 2 C +1"` (Level 20, 7 Quality Lightning Spear, with +1 Level Corruption and Count set to 2 )
--- * `"RhoaMountPlayer Rhoa Mount 10/0 DISABLED 1"` (Level 10, 0 quality Rhoa Mount with count 1 that has been disabled)
function SkillsTabClass:PasteSocketGroup(testInput)
local skillText = sanitiseText(Paste() or testInput)
if skillText then
Expand All @@ -642,8 +657,21 @@ function SkillsTabClass:PasteSocketGroup(testInput)
newGroup.slot = slot
end
for line in skillText:gmatch("([^\r\n]+)") do
local currentLine = line -- reassignment to local var to avoid modifying iter var

-- Check if specific skillId was provided via `testInput`
local skillId = currentLine:match("^skillId:(%w+) ")
if skillId then
currentLine = currentLine:gsub("^skillId:%w+ ", "")
end

local nameSpec, level, quality, state, count, cFlag, cLevel =
line:match("^([ %a':]+) (%d+)/(%d+)%s*(%u*)%s+([%d%.]+)%s*(C?)([+%-]?%d*)%s*$")
currentLine:match("^([ %a':]+) (%d+)/(%d+)%s*(%u*)%s+([%d%.]+)%s*(C?)([+%-]?%d*)%s*$")

-- Ignore invalid or mismatched skillId
if skillId and not (self.build.data.skills[skillId] and self.build.data.skills[skillId].baseTypeName == nameSpec) then
skillId = nil
end
if nameSpec then
local skillMinion = nil
local skillMinionCalcs = nil
Expand Down Expand Up @@ -685,7 +713,8 @@ function SkillsTabClass:PasteSocketGroup(testInput)
enableGlobal1 = true,
enableGlobal2 = true,
skillMinion = skillMinion,
skillMinionCalcs = skillMinionCalcs
skillMinionCalcs = skillMinionCalcs,
skillId = skillId
})
end
end
Expand Down
3 changes: 3 additions & 0 deletions src/Data/SkillStatMap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ return {
["skill_double_hits_when_dual_wielding"] = {
skill("doubleHitsWhenDualWielding", true),
},
["skill_combines_hits_when_dual_wielding"] = { -- NOTE: This is before PoE2 has "combined hit" dual wield skills, so stat will have to be updated in the future
skill("combinesHitsWhenDualWielding", true),
},
["support_spell_echo_number_of_echo_cascades"] = {
mod("RepeatCount", "BASE", nil, 0, 0, {type = "SkillType", skillType = SkillType.Cascadable }),
},
Expand Down
Loading
Loading