diff --git a/spec/System/TestFullDPSAutoTotems_spec.lua b/spec/System/TestFullDPSAutoTotems_spec.lua new file mode 100644 index 0000000000..523f96c256 --- /dev/null +++ b/spec/System/TestFullDPSAutoTotems_spec.lua @@ -0,0 +1,189 @@ +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 + + local function equipBow() + build.itemsTab:CreateDisplayItemFromRaw("Test Bow\nShort Bow") + build.itemsTab:AddDisplayItem() + end + + local function enableAutoCountWithExtraTotemLimit() + 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.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 + 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.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)) + -- 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.fullDPSAutoTotems = 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 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 + -- at its manual Count when more than one Totem source is included. + 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 + + enableAutoCountWithExtraTotemLimit() + + 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("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 + enableAutoCountWithExtraTotemLimit() + + 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 + enableAutoCountWithExtraTotemLimit() + + 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: 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") + 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.fullDPSAutoTotems = 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 917b29d35d..ce5b40ec01 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -174,6 +174,68 @@ local function getActiveSkillCount(activeSkill) return 1, 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, +} + +-- 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 + 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 context +end + +local function resolveFullDPSCount(env, activeSkill, activeSkillCount, context) + if activeSkillCount ~= 1 then + return activeSkillCount + end + 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) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil @@ -199,7 +261,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local igniteSource = "" local burningGroundSource = "" local causticGroundSource = "" - + + local fullDPSCountContext = buildFullDPSCountContext(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 +271,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv + 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 diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 98c98b6b9c..59c6cb3b86 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 = "fullDPSAutoTotems", + 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 },