From a7d7557cc5b8cfd29bf14041459efd0300774a76 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 12 May 2026 21:47:50 +0200 Subject: [PATCH 1/4] Add opt-in Full DPS auto-count for Totem skills Add a Configuration option "Auto-count Totems in Full DPS?" that lets Full DPS treat a Totem skill's count as the build's current TotemsSummoned (falling back to ActiveTotemLimit) when the skill's manual Count is 1. Behavior: - Default off: Full DPS uses the manual skill Count, unchanged. - Option on, Count == 1, skill has skillFlags.totem, not Explosive Arrow, and exactly one Totem source is included in Full DPS: Full DPS uses output.TotemsSummoned or output.ActiveTotemLimit. - Option on, Count > 1: manual Count wins. - Explosive Arrow is excluded from scaling because its custom DPS function already accounts for active totems, but it still counts as a Full DPS totem source for the multi-source guard. - Two or more Totem sources included in Full DPS (including Explosive Arrow): auto-count is suppressed and each skill keeps its manual Count, because ActiveTotemLimit is a global slot pool that cannot be allocated automatically across multiple sources. The implementation keeps two predicates intentionally separate: isIncludedFullDPSTotemSource (broader, used by the source counter) matches every included Totem skill that occupies a global totem slot; isFullDPSAutoTotemScalable (narrower, used by the per-skill scaling gate) additionally excludes Explosive Arrow. The option is gated by ifSkillFlag = "totem" so it only appears for builds containing at least one Totem skill, making it useful for comparison tools (Power Report, Compare tab, anoint sorting, trade query) to surface "+1 to maximum number of Summoned Totems" sources in single-totem-source Full DPS setups. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/System/TestFullDPSAutoTotems_spec.lua | 136 +++++++++++++++++++++ src/Modules/Calcs.lua | 57 ++++++++- src/Modules/ConfigOptions.lua | 13 ++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 spec/System/TestFullDPSAutoTotems_spec.lua diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua new file mode 100644 index 00000000000..2a0e16e1fbf --- /dev/null +++ b/spec/System/TestFullDPSAutoTotems_spec.lua @@ -0,0 +1,136 @@ +describe("TestFullDPSAutoTotems", function() + -- Holy Flame Totem is a direct hit-damage totem skill: its FullDPS contribution + -- comes through `usedEnv.player.output.TotalDPS * activeSkillCount`, which is the + -- exact code path the opt-in scaling targets. A custom mod raises ActiveTotemLimit + -- to 2 so a multiplier > 1 is observable. + local function setupHolyFlameTotemInFullDPS() + newBuild() + build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nHoly Flame Totem 20/0 1\n") + runCallback("OnFrame") + local socketGroup = build.skillsTab.socketGroupList[1] + socketGroup.includeInFullDPS = true + build.configTab.input.customMods = "+1 to maximum number of Summoned Totems" + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + return socketGroup + end + + teardown(function() + -- newBuild() resets state for the next describe block + end) + + it("does not enable the opt-in option by default", function() + newBuild() + assert.is_nil(build.configTab.input.fullDPSAutoMaxTotems) + end) + + it("Full DPS for a Totem skill uses skill count 1 when the option is off", function() + setupHolyFlameTotemInFullDPS() + local mainSkill = build.calcsTab.mainEnv.player.mainSkill + assert.is_true(mainSkill.skillFlags.totem) + local baselineFullDPS = build.calcsTab.mainOutput.FullDPS + assert.is_true(baselineFullDPS ~= nil and baselineFullDPS > 0) + local skillDPSEntries = build.calcsTab.mainOutput.SkillDPS + assert.are.equals(1, skillDPSEntries[1].count) + end) + + it("Full DPS scales by ActiveTotemLimit when the option is on", function() + setupHolyFlameTotemInFullDPS() + local baselineFullDPS = build.calcsTab.mainOutput.FullDPS + + build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + + local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit + assert.is_true(totemLimit > 1, "expected ActiveTotemLimit > 1, got " .. tostring(totemLimit)) + -- SkillDPS entry uses the scaled count, which is what comparison tools observe + assert.are.equals(totemLimit, build.calcsTab.mainOutput.SkillDPS[1].count) + -- Combined FullDPS strictly grows; an exact ratio is not asserted because some + -- components (ignite, burning ground) do not scale with totem count. + assert.is_true(build.calcsTab.mainOutput.FullDPS > baselineFullDPS) + end) + + it("manual Count > 1 wins over the auto-count option", function() + local socketGroup = setupHolyFlameTotemInFullDPS() + local baselineFullDPS = build.calcsTab.mainOutput.FullDPS + + socketGroup.groupCount = 5 + build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + + local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit + assert.is_true(totemLimit ~= 5, "test relies on ActiveTotemLimit being different from 5, got " .. tostring(totemLimit)) + assert.are.equals(5, build.calcsTab.mainOutput.SkillDPS[1].count) + assert.is_true(build.calcsTab.mainOutput.FullDPS > baselineFullDPS) + end) + + it("does not auto-scale when multiple Totem skills are included in Full DPS (avoids overcounting the global limit)", function() + -- Two distinct Totem socket groups both opted into Full DPS, both at Count 1. + -- ActiveTotemLimit is a global slot pool; applying it to each skill would + -- multi-count the same totem slots. The implementation must keep each skill + -- at its manual Count when more than one Totem source is included. + -- + -- Explosive Arrow Ballista in the same scenario is handled correctly by + -- construction in `src/Modules/Calcs.lua`: `isIncludedFullDPSTotemSource` + -- (used by the source counter) does NOT check `explosiveArrowFunc`, so an + -- EA Ballista source still increments the source count; only + -- `isFullDPSAutoTotemScalable` (used by the per-skill scaling gate) excludes + -- it. The two predicates cannot be conflated without editing the helpers + -- themselves. A spec-level test for the EA Ballista variant would require + -- additional weapon+support fixture wiring that the existing test harness + -- does not currently expose. + newBuild() + build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nHoly Flame Totem 20/0 1\n") + runCallback("OnFrame") + build.skillsTab.socketGroupList[1].includeInFullDPS = true + + build.skillsTab:PasteSocketGroup("Slot: Body Armour\nHoly Flame Totem 20/0 1\n") + runCallback("OnFrame") + build.skillsTab.socketGroupList[2].includeInFullDPS = true + + build.configTab.input.customMods = "+2 to maximum number of Summoned Totems" + build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + + local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit + assert.is_true(totemLimit > 1, "expected ActiveTotemLimit > 1, got " .. tostring(totemLimit)) + + local totemEntries = 0 + for _, entry in ipairs(build.calcsTab.mainOutput.SkillDPS) do + if entry.name == "Holy Flame Totem" then + assert.are.equals(1, entry.count, "Holy Flame Totem entry must stay at count 1 when multiple totem sources are included") + totemEntries = totemEntries + 1 + end + end + assert.are.equals(2, totemEntries, "expected both Holy Flame Totem socket groups in the Full DPS skill list") + end) + + it("uses the current TotemsSummoned override, not ActiveTotemLimit, when both are set", function() + -- Raise ActiveTotemLimit to 4 via custom mod, then set the existing TotemsSummoned + -- config to 2: getSummonedTotemCount reads output.TotemsSummoned first, so it must + -- land on 2, not 4. This pins the "current count" half of the tooltip contract. + newBuild() + build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nHoly Flame Totem 20/0 1\n") + runCallback("OnFrame") + local socketGroup = build.skillsTab.socketGroupList[1] + socketGroup.includeInFullDPS = true + build.configTab.input.customMods = "+3 to maximum number of Summoned Totems" + build.configTab.input.TotemsSummoned = 2 + build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + + local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit + assert.is_true(totemLimit > 2, "expected ActiveTotemLimit > TotemsSummoned override, got " .. tostring(totemLimit)) + assert.are.equals(2, build.calcsTab.mainOutput.TotemsSummoned) + assert.are.equals(2, build.calcsTab.mainOutput.SkillDPS[1].count) + end) +end) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 917b29d35d2..de70e72f4ab 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -174,6 +174,56 @@ local function getActiveSkillCount(activeSkill) return 1, true end +-- A Full DPS totem skill occupies a slot in the global totem-slot pool, regardless +-- of whether the generic auto-count operation is allowed to scale it. Explosive +-- Arrow Ballista is the notable case: its custom DPS function already models active +-- totems internally, so it must not be scaled again -- but it still consumes a +-- global totem slot and therefore counts as a Full DPS totem source. +local function isIncludedFullDPSTotemSource(activeSkill) + if not activeSkill.socketGroup or not activeSkill.socketGroup.includeInFullDPS then + return false + end + return activeSkill.skillFlags and activeSkill.skillFlags.totem == true +end + +local function isFullDPSAutoTotemScalable(activeSkill) + if not isIncludedFullDPSTotemSource(activeSkill) then + return false + end + -- Explosive Arrow already accounts for active totems in its custom DPS logic. + return not activeSkill.activeEffect.grantedEffect.explosiveArrowFunc +end + +local function countFullDPSTotemSources(activeSkillList) + local count = 0 + for _, activeSkill in ipairs(activeSkillList) do + if isIncludedFullDPSTotemSource(activeSkill) then + count = count + 1 + end + end + return count +end + +local function shouldScaleFullDPSBySummonedTotems(env, activeSkill, activeSkillCount, totemSourceCount) + if not env.configInput.fullDPSAutoMaxTotems then + return false + end + if activeSkillCount ~= 1 then + return false + end + if not isFullDPSAutoTotemScalable(activeSkill) then + return false + end + -- ActiveTotemLimit / TotemsSummoned is a global slot pool. With more than one + -- Totem source included in Full DPS (including Explosive Arrow), applying it to + -- each scalable skill would overcount; fall back to manual Count for the user. + return totemSourceCount == 1 +end + +local function getSummonedTotemCount(output) + return output.TotemsSummoned or output.ActiveTotemLimit or 1 +end + function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil @@ -199,7 +249,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local igniteSource = "" local burningGroundSource = "" local causticGroundSource = "" - + + local fullDPSAutoTotemSourceCount = countFullDPSTotemSources(fullEnv.player.activeSkillList) + for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = getActiveSkillCount(activeSkill) @@ -207,6 +259,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv + if shouldScaleFullDPSBySummonedTotems(fullEnv, activeSkill, activeSkillCount, fullDPSAutoTotemSourceCount) then + activeSkillCount = getSummonedTotemCount(usedEnv.player.output) + end local minionName = nil if activeSkill.minion or usedEnv.minion then if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 98c98b6b9c8..0a70821d3a8 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1113,6 +1113,19 @@ Huge sets the radius to 11. modList:NewMod("TotemsSummoned", "OVERRIDE", val, "Config", { type = "Condition", var = "Combat" }) modList:NewMod("Condition:HaveTotem", "FLAG", val >= 1, "Config", { type = "Condition", var = "Combat" }) end }, + { + var = "fullDPSAutoMaxTotems", + type = "check", + label = "Auto-count Totems in Full DPS?", + ifSkillFlag = "totem", + tooltip = + "If enabled, Full DPS will use your current number of Summoned Totems for Totem skills\n" + .. "when their skill Count is 1.\n\n" + .. "Manual Count values greater than 1 are still respected.\n\n" + .. "Only applies when a single Totem skill is included in Full DPS. With multiple\n" + .. "Totem skills, the global totem-slot pool cannot be allocated automatically and\n" + .. "manual Count is required for each.", + }, { var = "conditionSummonedGolemInPast8Sec", type = "check", label = "Summoned Golem in past 8 Seconds?", ifCond = "SummonedGolemInPast8Sec", implyCond = "SummonedGolemInPast10Sec", apply = function(val, modList, enemyModList) modList:NewMod("Condition:SummonedGolemInPast8Sec", "FLAG", true, "Config", { type = "Condition", var = "Combat" }) end }, From fc7f079cd54434927d52729b6b09c29d4e347db6 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 19:45:07 +0200 Subject: [PATCH 2/4] Use spellcheck-safe Totem test wording Keep the multi-source regression description clear while satisfying the tracked repository spellcheck. --- spec/System/TestFullDPSAutoTotems_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua index 2a0e16e1fbf..b685e811e71 100644 --- a/spec/System/TestFullDPSAutoTotems_spec.lua +++ b/spec/System/TestFullDPSAutoTotems_spec.lua @@ -69,7 +69,7 @@ describe("TestFullDPSAutoTotems", function() assert.is_true(build.calcsTab.mainOutput.FullDPS > baselineFullDPS) end) - it("does not auto-scale when multiple Totem skills are included in Full DPS (avoids overcounting the global limit)", function() + it("does not auto-scale when multiple Totem skills are included in Full DPS (avoids counting the global limit twice)", function() -- Two distinct Totem socket groups both opted into Full DPS, both at Count 1. -- ActiveTotemLimit is a global slot pool; applying it to each skill would -- multi-count the same totem slots. The implementation must keep each skill From c376c5d85a436c2f68b136fa8d2ec0b4801785e5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 09:24:15 +0200 Subject: [PATCH 3/4] Simplify Full DPS Totem auto-counting Count only enabled Full DPS sources, resolve automatic Totem counts in one place, and cover the Explosive Arrow exclusion explicitly. --- spec/System/TestFullDPSAutoTotems_spec.lua | 99 +++++++++++++++++----- src/Modules/Calcs.lua | 42 +++------ src/Modules/ConfigOptions.lua | 2 +- 3 files changed, 91 insertions(+), 52 deletions(-) diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua index b685e811e71..aa47b462649 100644 --- a/spec/System/TestFullDPSAutoTotems_spec.lua +++ b/spec/System/TestFullDPSAutoTotems_spec.lua @@ -16,13 +16,33 @@ describe("TestFullDPSAutoTotems", function() return socketGroup end - teardown(function() - -- newBuild() resets state for the next describe block - end) + local function equipBow() + build.itemsTab:CreateDisplayItemFromRaw("Test Bow\nShort Bow") + build.itemsTab:AddDisplayItem() + end + + local function enableAutoCount() + build.configTab.input.customMods = "+2 to maximum number of Summoned Totems" + build.configTab.input.fullDPSAutoTotems = true + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + end + + local function assertSkillCount(name, expected) + local matches = 0 + for _, entry in ipairs(build.calcsTab.mainOutput.SkillDPS) do + if entry.name == name then + assert.are.equals(expected, entry.count, name .. " Full DPS count") + matches = matches + 1 + end + end + assert.is_true(matches > 0, "expected a Full DPS entry for " .. name) + end it("does not enable the opt-in option by default", function() newBuild() - assert.is_nil(build.configTab.input.fullDPSAutoMaxTotems) + assert.is_nil(build.configTab.input.fullDPSAutoTotems) end) it("Full DPS for a Totem skill uses skill count 1 when the option is off", function() @@ -39,7 +59,7 @@ describe("TestFullDPSAutoTotems", function() setupHolyFlameTotemInFullDPS() local baselineFullDPS = build.calcsTab.mainOutput.FullDPS - build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab.input.fullDPSAutoTotems = true build.configTab:BuildModList() build.buildFlag = true runCallback("OnFrame") @@ -58,7 +78,7 @@ describe("TestFullDPSAutoTotems", function() local baselineFullDPS = build.calcsTab.mainOutput.FullDPS socketGroup.groupCount = 5 - build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab.input.fullDPSAutoTotems = true build.configTab:BuildModList() build.buildFlag = true runCallback("OnFrame") @@ -74,16 +94,6 @@ describe("TestFullDPSAutoTotems", function() -- ActiveTotemLimit is a global slot pool; applying it to each skill would -- multi-count the same totem slots. The implementation must keep each skill -- at its manual Count when more than one Totem source is included. - -- - -- Explosive Arrow Ballista in the same scenario is handled correctly by - -- construction in `src/Modules/Calcs.lua`: `isIncludedFullDPSTotemSource` - -- (used by the source counter) does NOT check `explosiveArrowFunc`, so an - -- EA Ballista source still increments the source count; only - -- `isFullDPSAutoTotemScalable` (used by the per-skill scaling gate) excludes - -- it. The two predicates cannot be conflated without editing the helpers - -- themselves. A spec-level test for the EA Ballista variant would require - -- additional weapon+support fixture wiring that the existing test harness - -- does not currently expose. newBuild() build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nHoly Flame Totem 20/0 1\n") runCallback("OnFrame") @@ -93,11 +103,7 @@ describe("TestFullDPSAutoTotems", function() runCallback("OnFrame") build.skillsTab.socketGroupList[2].includeInFullDPS = true - build.configTab.input.customMods = "+2 to maximum number of Summoned Totems" - build.configTab.input.fullDPSAutoMaxTotems = true - build.configTab:BuildModList() - build.buildFlag = true - runCallback("OnFrame") + enableAutoCount() local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit assert.is_true(totemLimit > 1, "expected ActiveTotemLimit > 1, got " .. tostring(totemLimit)) @@ -112,9 +118,56 @@ describe("TestFullDPSAutoTotems", function() assert.are.equals(2, totemEntries, "expected both Holy Flame Totem socket groups in the Full DPS skill list") end) + it("ignores disabled Vaal Totem skills when counting Full DPS sources", function() + setupHolyFlameTotemInFullDPS() + build.skillsTab:PasteSocketGroup("Slot: Body Armour\nVaal Rejuvenation Totem 20/0 1\n") + runCallback("OnFrame") + local socketGroup = build.skillsTab.socketGroupList[2] + socketGroup.includeInFullDPS = true + socketGroup.gemList[1].enableGlobal1 = false + socketGroup.gemList[1].enableGlobal2 = false + build.configTab.input.fullDPSAutoTotems = true + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + + local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit + assert.is_true(totemLimit > 1, "expected ActiveTotemLimit > 1, got " .. tostring(totemLimit)) + assertSkillCount("Holy Flame Totem", totemLimit) + end) + + it("does not auto-scale Explosive Arrow Ballista", function() + newBuild() + equipBow() + build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nExplosive Arrow 20/0 1\nBallista Totem 20/0 1\n") + runCallback("OnFrame") + build.skillsTab.socketGroupList[1].includeInFullDPS = true + enableAutoCount() + + assert.is_true(build.calcsTab.mainOutput.ActiveTotemLimit > 1) + assertSkillCount("Explosive Arrow", 1) + end) + + it("counts Explosive Arrow as a second Totem source", function() + newBuild() + equipBow() + build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nExplosive Arrow 20/0 1\nBallista Totem 20/0 1\n") + runCallback("OnFrame") + build.skillsTab.socketGroupList[1].includeInFullDPS = true + + build.skillsTab:PasteSocketGroup("Slot: Body Armour\nHoly Flame Totem 20/0 1\n") + runCallback("OnFrame") + build.skillsTab.socketGroupList[2].includeInFullDPS = true + enableAutoCount() + + assert.is_true(build.calcsTab.mainOutput.ActiveTotemLimit > 1) + assertSkillCount("Explosive Arrow", 1) + assertSkillCount("Holy Flame Totem", 1) + end) + it("uses the current TotemsSummoned override, not ActiveTotemLimit, when both are set", function() -- Raise ActiveTotemLimit to 4 via custom mod, then set the existing TotemsSummoned - -- config to 2: getSummonedTotemCount reads output.TotemsSummoned first, so it must + -- config to 2: the Full DPS count reads output.TotemsSummoned first, so it must -- land on 2, not 4. This pins the "current count" half of the tooltip contract. newBuild() build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nHoly Flame Totem 20/0 1\n") @@ -123,7 +176,7 @@ describe("TestFullDPSAutoTotems", function() socketGroup.includeInFullDPS = true build.configTab.input.customMods = "+3 to maximum number of Summoned Totems" build.configTab.input.TotemsSummoned = 2 - build.configTab.input.fullDPSAutoMaxTotems = true + build.configTab.input.fullDPSAutoTotems = true build.configTab:BuildModList() build.buildFlag = true runCallback("OnFrame") diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index de70e72f4ab..f0dc2429fcc 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -183,15 +183,8 @@ local function isIncludedFullDPSTotemSource(activeSkill) if not activeSkill.socketGroup or not activeSkill.socketGroup.includeInFullDPS then return false end - return activeSkill.skillFlags and activeSkill.skillFlags.totem == true -end - -local function isFullDPSAutoTotemScalable(activeSkill) - if not isIncludedFullDPSTotemSource(activeSkill) then - return false - end - -- Explosive Arrow already accounts for active totems in its custom DPS logic. - return not activeSkill.activeEffect.grantedEffect.explosiveArrowFunc + local _, enabled = getActiveSkillCount(activeSkill) + return enabled and activeSkill.skillFlags and activeSkill.skillFlags.totem == true end local function countFullDPSTotemSources(activeSkillList) @@ -204,24 +197,19 @@ local function countFullDPSTotemSources(activeSkillList) return count end -local function shouldScaleFullDPSBySummonedTotems(env, activeSkill, activeSkillCount, totemSourceCount) - if not env.configInput.fullDPSAutoMaxTotems then - return false - end - if activeSkillCount ~= 1 then - return false - end - if not isFullDPSAutoTotemScalable(activeSkill) then - return false - end +local function getFullDPSTotemCount(env, activeSkill, activeSkillCount, totemSourceCount) -- ActiveTotemLimit / TotemsSummoned is a global slot pool. With more than one -- Totem source included in Full DPS (including Explosive Arrow), applying it to -- each scalable skill would overcount; fall back to manual Count for the user. - return totemSourceCount == 1 -end - -local function getSummonedTotemCount(output) - return output.TotemsSummoned or output.ActiveTotemLimit or 1 + if not env.configInput.fullDPSAutoTotems + or activeSkillCount ~= 1 + or totemSourceCount ~= 1 + or not isIncludedFullDPSTotemSource(activeSkill) + or activeSkill.activeEffect.grantedEffect.explosiveArrowFunc then + return activeSkillCount + end + local output = env.player.output + return output.TotemsSummoned or output.ActiveTotemLimit or activeSkillCount end function calcs.calcFullDPS(build, mode, override, specEnv) @@ -250,7 +238,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local burningGroundSource = "" local causticGroundSource = "" - local fullDPSAutoTotemSourceCount = countFullDPSTotemSources(fullEnv.player.activeSkillList) + local fullDPSTotemSourceCount = countFullDPSTotemSources(fullEnv.player.activeSkillList) for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then @@ -259,9 +247,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv - if shouldScaleFullDPSBySummonedTotems(fullEnv, activeSkill, activeSkillCount, fullDPSAutoTotemSourceCount) then - activeSkillCount = getSummonedTotemCount(usedEnv.player.output) - end + activeSkillCount = getFullDPSTotemCount(fullEnv, activeSkill, activeSkillCount, fullDPSTotemSourceCount) local minionName = nil if activeSkill.minion or usedEnv.minion then if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 0a70821d3a8..59c6cb3b86d 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1114,7 +1114,7 @@ Huge sets the radius to 11. modList:NewMod("Condition:HaveTotem", "FLAG", val >= 1, "Config", { type = "Condition", var = "Combat" }) end }, { - var = "fullDPSAutoMaxTotems", + var = "fullDPSAutoTotems", type = "check", label = "Auto-count Totems in Full DPS?", ifSkillFlag = "totem", From f270bc5fa8f6ee4d296fca7a0e534fa403a0518b Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 16:17:30 +0200 Subject: [PATCH 4/4] Generalize Full DPS Count resolution Keep calcFullDPS independent of Totem rules while preserving the existing opt-in behavior behind a private pool-aware policy. Addresses the architecture review for PR 9849. --- spec/System/TestFullDPSAutoTotems_spec.lua | 8 +-- src/Modules/Calcs.lua | 84 ++++++++++++++-------- 2 files changed, 58 insertions(+), 34 deletions(-) diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua index aa47b462649..523f96c256c 100644 --- a/spec/System/TestFullDPSAutoTotems_spec.lua +++ b/spec/System/TestFullDPSAutoTotems_spec.lua @@ -21,7 +21,7 @@ describe("TestFullDPSAutoTotems", function() build.itemsTab:AddDisplayItem() end - local function enableAutoCount() + local function enableAutoCountWithExtraTotemLimit() build.configTab.input.customMods = "+2 to maximum number of Summoned Totems" build.configTab.input.fullDPSAutoTotems = true build.configTab:BuildModList() @@ -103,7 +103,7 @@ describe("TestFullDPSAutoTotems", function() runCallback("OnFrame") build.skillsTab.socketGroupList[2].includeInFullDPS = true - enableAutoCount() + enableAutoCountWithExtraTotemLimit() local totemLimit = build.calcsTab.mainOutput.ActiveTotemLimit assert.is_true(totemLimit > 1, "expected ActiveTotemLimit > 1, got " .. tostring(totemLimit)) @@ -142,7 +142,7 @@ describe("TestFullDPSAutoTotems", function() build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nExplosive Arrow 20/0 1\nBallista Totem 20/0 1\n") runCallback("OnFrame") build.skillsTab.socketGroupList[1].includeInFullDPS = true - enableAutoCount() + enableAutoCountWithExtraTotemLimit() assert.is_true(build.calcsTab.mainOutput.ActiveTotemLimit > 1) assertSkillCount("Explosive Arrow", 1) @@ -158,7 +158,7 @@ describe("TestFullDPSAutoTotems", function() build.skillsTab:PasteSocketGroup("Slot: Body Armour\nHoly Flame Totem 20/0 1\n") runCallback("OnFrame") build.skillsTab.socketGroupList[2].includeInFullDPS = true - enableAutoCount() + enableAutoCountWithExtraTotemLimit() assert.is_true(build.calcsTab.mainOutput.ActiveTotemLimit > 1) assertSkillCount("Explosive Arrow", 1) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index f0dc2429fcc..ce5b40ec01a 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -174,42 +174,66 @@ local function getActiveSkillCount(activeSkill) return 1, true end --- A Full DPS totem skill occupies a slot in the global totem-slot pool, regardless --- of whether the generic auto-count operation is allowed to scale it. Explosive --- Arrow Ballista is the notable case: its custom DPS function already models active --- totems internally, so it must not be scaled again -- but it still consumes a --- global totem slot and therefore counts as a Full DPS totem source. -local function isIncludedFullDPSTotemSource(activeSkill) - if not activeSkill.socketGroup or not activeSkill.socketGroup.includeInFullDPS then - return false - end - local _, enabled = getActiveSkillCount(activeSkill) - return enabled and activeSkill.skillFlags and activeSkill.skillFlags.totem == true -end +local fullDPSTotemPoolKey = { } +local fullDPSTotemCountPolicy = { + getPoolKey = function(activeSkill, enabled) + -- Explosive Arrow Ballista occupies the same global slot pool as other Totem + -- skills even though its custom DPS function already models active Totems. + if enabled + and activeSkill.socketGroup + and activeSkill.socketGroup.includeInFullDPS + and activeSkill.skillFlags + and activeSkill.skillFlags.totem == true then + return fullDPSTotemPoolKey + end + end, + resolveCount = function(env, activeSkill, sourceCount) + -- TotemsSummoned / ActiveTotemLimit is a global slot pool. With more than one + -- participating source (including Explosive Arrow), allocating it to one skill + -- would be ambiguous, so the manual Count remains authoritative. + if not env.configInput.fullDPSAutoTotems + or sourceCount ~= 1 + or activeSkill.activeEffect.grantedEffect.explosiveArrowFunc then + return + end + local output = env.player.output + return output.TotemsSummoned or output.ActiveTotemLimit + end, +} -local function countFullDPSTotemSources(activeSkillList) - local count = 0 +-- A policy returns the same pool key for every source that shares a limited pool. +-- resolveCount returns an automatic Count or nil to keep the manual Count. Policies +-- are ordered; the first policy that returns a pool key owns the skill. +local fullDPSCountPolicies = { + fullDPSTotemCountPolicy, +} + +local function buildFullDPSCountContext(activeSkillList) + local context = { sourceCountByPoolKey = { } } for _, activeSkill in ipairs(activeSkillList) do - if isIncludedFullDPSTotemSource(activeSkill) then - count = count + 1 + local _, enabled = getActiveSkillCount(activeSkill) + for _, policy in ipairs(fullDPSCountPolicies) do + local poolKey = policy.getPoolKey(activeSkill, enabled) + if poolKey then + context.sourceCountByPoolKey[poolKey] = (context.sourceCountByPoolKey[poolKey] or 0) + 1 + break + end end end - return count + return context end -local function getFullDPSTotemCount(env, activeSkill, activeSkillCount, totemSourceCount) - -- ActiveTotemLimit / TotemsSummoned is a global slot pool. With more than one - -- Totem source included in Full DPS (including Explosive Arrow), applying it to - -- each scalable skill would overcount; fall back to manual Count for the user. - if not env.configInput.fullDPSAutoTotems - or activeSkillCount ~= 1 - or totemSourceCount ~= 1 - or not isIncludedFullDPSTotemSource(activeSkill) - or activeSkill.activeEffect.grantedEffect.explosiveArrowFunc then +local function resolveFullDPSCount(env, activeSkill, activeSkillCount, context) + if activeSkillCount ~= 1 then return activeSkillCount end - local output = env.player.output - return output.TotemsSummoned or output.ActiveTotemLimit or activeSkillCount + for _, policy in ipairs(fullDPSCountPolicies) do + local poolKey = policy.getPoolKey(activeSkill, true) + if poolKey then + return policy.resolveCount(env, activeSkill, context.sourceCountByPoolKey[poolKey] or 0) or activeSkillCount + end + end + return activeSkillCount end function calcs.calcFullDPS(build, mode, override, specEnv) @@ -238,7 +262,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local burningGroundSource = "" local causticGroundSource = "" - local fullDPSTotemSourceCount = countFullDPSTotemSources(fullEnv.player.activeSkillList) + local fullDPSCountContext = buildFullDPSCountContext(fullEnv.player.activeSkillList) for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then @@ -247,7 +271,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv - activeSkillCount = getFullDPSTotemCount(fullEnv, activeSkill, activeSkillCount, fullDPSTotemSourceCount) + activeSkillCount = resolveFullDPSCount(fullEnv, activeSkill, activeSkillCount, fullDPSCountContext) local minionName = nil if activeSkill.minion or usedEnv.minion then if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then