Skip to content

Commit 0d6108b

Browse files
majochemLocalIdentity
andauthored
Fix dual wield skills with "both weapons hit..." combining into a single hit (#2006)
* Change combined hit flag to `combinesHitsWhenDualWielding` In PoE2 `doubleHitsWhenDualWielding` does not actually cause a combined hit from both weapons, but instead causes two separate near simultaneous hits. In order to still be able to reuse the old logic, I changed all checks for `doublenHitsWhenDualWielding` to `combinesHitsWhenDualWielding` instead. Support for the old flag will be in separate commit. * Make `combineStat("HARMONICMEAN")` use double hits * Update the `Speed` breakdown Now correctly reflects behavior of the three possible dual wield hitRate scenarios: 1. One combined hit from both weapons (Harmonic Mean) 2. Two separate simultaneous hits from each weapon (2x Harmonic Mean) 3. Alternating between Mainhand and Offhand (Harmonic Mean) * Use "DPS" mod instead of directly changing `Speed` This is still not ideal imo, but it's mostly due to the fact that we don't have a good breakdown for "DPS" mods * Add nil check for `gemName` in CalcSetup Found this bug when expanding `PasteSocketGroup()`, which probably wasn't a problem until now because it could only occur with `testInput` * Add option specify `skillId` to `PasteSocketGroup` Also added emmylua annotations for easier understanding of the `testInput` structure * Add `dpsMultiplier` to `output` to avoid doubling Since `skillData.dpsMultiplier` was multiplied with itself in each pass, any modifiers would be applied twice for dual wield skills. Instead it is now added to `output.DpsMultiplier` and combined with `combineStat()` * Add stat for future combined hit dual wield skills * Add tests for dual wield stats * small fix for test conditions * Add rounding to tests Previously got a failed test because DPS value were not equal after 10 decimals ... * Fix breakdown for average hit & attack speed * Fix label logic Was excluding grantedEffect.name skills --------- Co-authored-by: LocalIdentity <localidentity2@gmail.com>
1 parent 4fe3193 commit 0d6108b

5 files changed

Lines changed: 256 additions & 37 deletions

File tree

spec/System/TestAttacks_spec.lua

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,166 @@ describe("TestAttacks", function()
418418
assert.is_true(math.abs(bifurcateChance - build.calcsTab.mainOutput.MainHand.CritBifurcates) < 0.000001)
419419
assert.are.equals(1 + critBonusMultiplier, build.calcsTab.mainOutput.MainHand.AverageHit)
420420
end)
421+
422+
-- Dual Wield tests
423+
local setupDualWieldTestConditions = function()
424+
local slowHighDmgMace = [[
425+
Slow High Crit High Damage Mace
426+
Marauding Mace
427+
Quality: 0
428+
200% increased physical damage
429+
100% increased critical hit chance
430+
-25% increased attack speed
431+
]]
432+
433+
local fastLowDmgMace = [[
434+
Fast Low Crit Low Damage Mace
435+
Marauding Mace
436+
Quality: 0
437+
-50% increased physical damage
438+
-100% increased critical hit chance
439+
50% increased attack speed
440+
]]
441+
442+
build.itemsTab:CreateDisplayItemFromRaw(slowHighDmgMace)
443+
build.itemsTab:AddDisplayItem()
444+
runCallback("OnFrame")
445+
build.itemsTab.slots["Weapon 1"]:SetSelItemId(build.itemsTab.items[1].id)
446+
447+
build.itemsTab:CreateDisplayItemFromRaw(fastLowDmgMace)
448+
build.itemsTab:AddDisplayItem()
449+
runCallback("OnFrame")
450+
build.itemsTab.slots["Weapon 2"]:SetSelItemId(build.itemsTab.items[2].id)
451+
452+
build.configTab.input.customMods = [[
453+
nearby enemies have 100% less armour
454+
your hits can't be evaded
455+
]]
456+
build.configTab:BuildModList()
457+
runCallback("OnFrame")
458+
end
459+
460+
local function harmonicMean(a, b)
461+
return 2 / (1/a + 1/b)
462+
end
463+
464+
it("correctly calculates dual wield DPS for double hits", function()
465+
setupDualWieldTestConditions()
466+
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1")
467+
runCallback("OnFrame")
468+
build.calcsTab:BuildOutput()
469+
runCallback("OnFrame")
470+
471+
-- Attack Speed
472+
local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed
473+
local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed
474+
local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed)
475+
assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4))
476+
477+
-- Average Hit
478+
local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage
479+
local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage
480+
local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage
481+
assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg) / 2, 4), round(combinedAvgDmg, 4))
482+
483+
-- DPS (hits twice per attack)
484+
local combinedDPS = build.calcsTab.mainOutput.TotalDPS
485+
assert.are.equals(round(combinedAvgDmg * combinedSpeed * 2,4), round(combinedDPS,4))
486+
end)
487+
488+
it("correctly calculates dual wield crit chance for double hits", function()
489+
setupDualWieldTestConditions()
490+
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1")
491+
runCallback("OnFrame")
492+
build.calcsTab:BuildOutput()
493+
runCallback("OnFrame")
494+
495+
-- Double hits roll crit individually per weapon, so should be average
496+
local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance
497+
local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance
498+
local combinedCritChance = (mainHandCritChance + offHandCritChance) / 2
499+
assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance)
500+
end)
501+
502+
it("correctly calculates dual wield DPS for alternating hits", function()
503+
setupDualWieldTestConditions()
504+
build.skillsTab:PasteSocketGroup("Armour Breaker 20/0 1")
505+
runCallback("OnFrame")
506+
build.calcsTab:BuildOutput()
507+
runCallback("OnFrame")
508+
509+
-- Attack Speed
510+
local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed
511+
local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed
512+
local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed)
513+
assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4))
514+
515+
-- Average Hit
516+
local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage
517+
local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage
518+
local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage
519+
assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg) / 2, 4), round(combinedAvgDmg, 4))
520+
521+
-- DPS (hits once per attack)
522+
local combinedDPS = build.calcsTab.mainOutput.TotalDPS
523+
assert.are.equals(round(combinedAvgDmg * combinedSpeed, 4), round(combinedDPS, 4))
524+
end)
525+
526+
it("correctly calculates dual wield crit chance for alternating hits", function()
527+
setupDualWieldTestConditions()
528+
build.skillsTab:PasteSocketGroup("Armour Breaker 20/0 1")
529+
runCallback("OnFrame")
530+
build.calcsTab:BuildOutput()
531+
runCallback("OnFrame")
532+
533+
-- Alternating hits roll crit individually per weapon, so should be average
534+
local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance
535+
local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance
536+
local combinedCritChance = (mainHandCritChance + offHandCritChance) / 2
537+
assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance)
538+
end)
539+
540+
--[[
541+
NOTE: the following section contains tests for "combined hits", which PoE2 doesn't have as of 2026-06-02,
542+
which means the tests were written for a temporary test skill that will not be committed.
543+
The test can be updated by simply replacing `"skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1"`
544+
with actual skill data once available
545+
]]
546+
--[[ it("correctly calculates dual wield DPS for combined hits", function()
547+
setupDualWieldTestConditions()
548+
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1")
549+
runCallback("OnFrame")
550+
build.calcsTab:BuildOutput()
551+
runCallback("OnFrame")
552+
553+
-- Attack Speed
554+
local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed
555+
local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed
556+
local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed)
557+
assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4))
558+
559+
-- Average Hit
560+
local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage
561+
local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage
562+
local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage
563+
assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg), 4), round(combinedAvgDmg, 4))
564+
565+
-- DPS (hits twice per attack)
566+
local combinedDPS = build.calcsTab.mainOutput.TotalDPS
567+
assert.are.equals(round(combinedAvgDmg * combinedSpeed, 4), round(combinedDPS,4))
568+
end)
569+
570+
it("correctly calculates dual wield crit chance for combined hits", function()
571+
setupDualWieldTestConditions()
572+
build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1")
573+
runCallback("OnFrame")
574+
build.calcsTab:BuildOutput()
575+
runCallback("OnFrame")
576+
577+
-- combined hits count whole attack as crit, as long as one hand rolls crit)
578+
local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance
579+
local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance
580+
local combinedCritChance = mainHandCritChance + offHandCritChance - (mainHandCritChance * offHandCritChance / 100)
581+
assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance)
582+
end) ]]
421583
end)

src/Classes/SkillsTab.lua

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,21 @@ function SkillsTabClass:CopySocketGroup(socketGroup)
629629
Copy(skillText)
630630
end
631631

632+
--- Parses pasted socketGroup or custom test string to generate new socketGroup
633+
--- @param testInput string? optional string input mainly used for tests
634+
---
635+
--- **Expected `testInput` Format:**
636+
--- ```text
637+
--- [Label: <Group Label>] (Optional)
638+
--- [Slot: <Equipment Slot>] (Optional)
639+
--- [skillId:<id>] <Skill Name> <Level>/<Quality> [STATE] <Count> [C] [+/-CorruptLevel]
640+
--- ```
641+
--- `skillId` is only needed if skill name is not unique
642+
--- `STATE` only used to disable gem via `DISABLED`
643+
---
644+
--- **Example lines:**
645+
--- * `"skillId:LightningSpearPlayer Lightning Spear 20/7 2 C +1"` (Level 20, 7 Quality Lightning Spear, with +1 Level Corruption and Count set to 2 )
646+
--- * `"RhoaMountPlayer Rhoa Mount 10/0 DISABLED 1"` (Level 10, 0 quality Rhoa Mount with count 1 that has been disabled)
632647
function SkillsTabClass:PasteSocketGroup(testInput)
633648
local skillText = sanitiseText(Paste() or testInput)
634649
if skillText then
@@ -642,8 +657,21 @@ function SkillsTabClass:PasteSocketGroup(testInput)
642657
newGroup.slot = slot
643658
end
644659
for line in skillText:gmatch("([^\r\n]+)") do
660+
local currentLine = line -- reassignment to local var to avoid modifying iter var
661+
662+
-- Check if specific skillId was provided via `testInput`
663+
local skillId = currentLine:match("^skillId:(%w+) ")
664+
if skillId then
665+
currentLine = currentLine:gsub("^skillId:%w+ ", "")
666+
end
667+
645668
local nameSpec, level, quality, state, count, cFlag, cLevel =
646-
line:match("^([ %a':]+) (%d+)/(%d+)%s*(%u*)%s+([%d%.]+)%s*(C?)([+%-]?%d*)%s*$")
669+
currentLine:match("^([ %a':]+) (%d+)/(%d+)%s*(%u*)%s+([%d%.]+)%s*(C?)([+%-]?%d*)%s*$")
670+
671+
-- Ignore invalid or mismatched skillId
672+
if skillId and not (self.build.data.skills[skillId] and self.build.data.skills[skillId].baseTypeName == nameSpec) then
673+
skillId = nil
674+
end
647675
if nameSpec then
648676
local skillMinion = nil
649677
local skillMinionCalcs = nil
@@ -685,7 +713,8 @@ function SkillsTabClass:PasteSocketGroup(testInput)
685713
enableGlobal1 = true,
686714
enableGlobal2 = true,
687715
skillMinion = skillMinion,
688-
skillMinionCalcs = skillMinionCalcs
716+
skillMinionCalcs = skillMinionCalcs,
717+
skillId = skillId
689718
})
690719
end
691720
end

src/Data/SkillStatMap.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ return {
282282
["skill_double_hits_when_dual_wielding"] = {
283283
skill("doubleHitsWhenDualWielding", true),
284284
},
285+
["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
286+
skill("combinesHitsWhenDualWielding", true),
287+
},
285288
["support_spell_echo_number_of_echo_cascades"] = {
286289
mod("RepeatCount", "BASE", nil, 0, 0, {type = "SkillType", skillType = SkillType.Cascadable }),
287290
},

0 commit comments

Comments
 (0)