From 54fe47ce11730992ccd5ded4cef62c9463e0deee Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 12 May 2026 21:47:50 +0200 Subject: [PATCH 1/6] Add opt-in Totem counting to Full DPS When enabled, use TotemsSummoned or ActiveTotemLimit when the manual Count is 1. Manual counts still win, and multiple Totem sources keep their existing counts. Explosive Arrow remains excluded from automatic scaling because its DPS calculation already accounts for active Totems. 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 dc12041374f..8db69979554 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -139,6 +139,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 @@ -164,7 +214,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) @@ -172,6 +224,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 8ff3e6f8c79..3b2f8359dbf 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1124,6 +1124,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 59331713f3968b890e5d92359b8de9610575a98f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 17 Aug 2026 19:45:07 +0200 Subject: [PATCH 2/6] Clarify the shared Totem limit test Use project terminology for the case where two Totem skills share one global limit. --- 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 34db685c2a1159aa110b4059cfe54b9279b42ea0 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 09:24:15 +0200 Subject: [PATCH 3/6] 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 8db69979554..12306623cd4 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -148,15 +148,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) @@ -169,24 +162,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) @@ -215,7 +203,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 @@ -224,9 +212,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 3b2f8359dbf..82138927316 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1125,7 +1125,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 f9c75c03c2c1680d1d705492d3fc4c7b2a82c903 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 16:17:30 +0200 Subject: [PATCH 4/6] Keep Totem rules out of Full DPS aggregation Keep calcFullDPS generic by passing resolved counts through its Count context. Totem-specific pool rules stay in the caller. --- 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 12306623cd4..e45f9291545 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -139,42 +139,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) @@ -203,7 +227,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 @@ -212,7 +236,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 From 26cf8131d909205b226780d84e60a923b4397b1c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 29 Aug 2026 08:20:08 +0200 Subject: [PATCH 5/6] Explain the shared Totem pool key The private table is an identity key for one shared Totem slot pool; its contents are intentionally unused. --- src/Modules/Calcs.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index e45f9291545..1b3e4d5d4d6 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -139,6 +139,8 @@ local function getActiveSkillCount(activeSkill) return 1, true end +-- Lua tables compare by identity. Returning this same private key for each Totem +-- source groups them into one global slot pool in the generic Count context. local fullDPSTotemPoolKey = { } local fullDPSTotemCountPolicy = { getPoolKey = function(activeSkill, enabled) From 11fdd5b0454d4e52f66f9eee6e6a7284d859512e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 27 Sep 2026 10:34:08 +0200 Subject: [PATCH 6/6] Remove a redundant Totem default test The existing Full DPS test already verifies that the option starts disabled. --- spec/System/TestFullDPSAutoTotems_spec.lua | 5 ----- 1 file changed, 5 deletions(-) diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua index 523f96c256c..c3ca8d98b0a 100644 --- a/spec/System/TestFullDPSAutoTotems_spec.lua +++ b/spec/System/TestFullDPSAutoTotems_spec.lua @@ -40,11 +40,6 @@ describe("TestFullDPSAutoTotems", function() 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.fullDPSAutoTotems) - 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