diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua new file mode 100644 index 0000000000..2a0e16e1fb --- /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 871d53587f..a0870ae25b 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -173,6 +173,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 @@ -198,7 +248,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) @@ -206,6 +258,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 e77099db2d..6476f51b1f 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1099,6 +1099,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 },