diff --git a/spec/System/TestTriggers_spec.lua b/spec/System/TestTriggers_spec.lua index a1b2268a67c..72f9ec6e4aa 100644 --- a/spec/System/TestTriggers_spec.lua +++ b/spec/System/TestTriggers_spec.lua @@ -909,6 +909,76 @@ describe("TestTriggers", function() assert.True(build.calcsTab.mainOutput.SkillTriggerRate ~= nil) end) + for _, resource in ipairs({ "Mana", "Life" }) do + it("preserves Kitava Full DPS across shared source calculations and " .. resource .. " cost overrides", function() + local triggerName = resource == "Life" and "Foulborn Kitava's Thirst" or "Kitava's Thirst" + local threshold = resource == "Life" and 200 or 100 + build.itemsTab:CreateDisplayItemFromRaw("Rarity: UNIQUE\n" .. triggerName .. "\nZealot Helmet\nImplicits: 0\n50% chance to Trigger Socketed Spells when you Spend at least " .. threshold .. " " .. resource .. " on an Upfront Cost to Use or Trigger a Skill, with a 0.1 second Cooldown") + build.itemsTab:AddDisplayItem() + build.configTab.input.customMods = "2000% increased Cost of Skills\n100% increased Cast Speed" .. (resource == "Life" and "\nSkills Cost Life instead of Mana" or "") + build.configTab:BuildModList() + -- The source's own Full DPS pass replaces its cached entry between helmet spells; Clarity is a rejected candidate + for _, group in ipairs({ "Slot: Helmet\nArc", "Slot: Body Armour\nFireball", "Slot: Helmet\nIce Nova", "Slot: Helmet\nSpark" }) do + build.skillsTab:PasteSocketGroup(group .. " 20/0 1\n") + end + for _, group in ipairs(build.skillsTab.socketGroupList) do + group.includeInFullDPS = true + end + build.skillsTab:PasteSocketGroup("Slot: Gloves\nClarity 1/0 1\n") + build.buildFlag = true + runCallback("OnFrame") + + local calc = build.calcsTab.calcs.getMiscCalculator(build) + local baseline = calc({}, true) + assert.are.equal(build.calcsTab.mainOutput.FullDPS, baseline.FullDPS) + for _, case in ipairs({ + { mod = "", rate = 8 / 3, dps = { 929.06, 3217.1333333333332, 970.0625, 716.53666666666663, 1403.4792533755865 } }, + { mod = "100% increased Cast Speed", rate = 4, dps = { 1393.59, 4825.7, 1455.09375, 1074.805, 1503.771511006951 } }, + { mod = "3000% reduced Cost of Skills", dps = { 2322.65, 3217.1333333333332, 2078.7053571428573, 1791.3416666666669, 1403.4792533755865 } }, + { mod = "", rate = 8 / 3, dps = { 929.06, 3217.1333333333332, 970.0625, 716.53666666666663, 1403.4792533755865 } }, + }) do + local override = case.mod ~= "" and { repSlotName = "Ring 1", repItem = new("Item"):Item("Rarity: RARE\nCost and Speed Ring\nIron Ring\nImplicits: 0\n" .. case.mod) } or {} + local output = calc(override, true) + assert.are.equal(case.rate, output.EffectiveSourceRate) + local skillDPS = {} + for index, row in ipairs(output.SkillDPS) do + skillDPS[index] = row.dps + end + assert.are.same(case.dps, skillDPS) + end + -- A reused rejected environment must still see the override's allocated notables + table.insert(build.skillsTab.socketGroupList, 2, table.remove(build.skillsTab.socketGroupList)) + build.configTab.input.customMods = build.configTab.input.customMods .. "\n100% increased Cast Speed per allocated Notable Passive Skill" + build.configTab:BuildModList() + runCallback("OnFrame") + local notable = build.spec.nodes[build.spec.tree.notableMap["deep wisdom"].id] + assert.are.equal(4, build.calcsTab.calcs.getMiscCalculator(build)({ addNodes = { [notable] = true } }, true).EffectiveSourceRate) + end) + end + + it("does not let Reflection reuse a rejected Kitava source from an earlier override", function() + build.itemsTab:CreateDisplayItemFromRaw("Rarity: UNIQUE\nFoulborn Kitava's Thirst\nZealot Helmet\nImplicits: 0\n50% chance to Trigger Socketed Spells when you Spend at least 200 Life on an Upfront Cost to Use or Trigger a Skill, with a 0.1 second Cooldown") + build.itemsTab:AddDisplayItem() + build.itemsTab:CreateDisplayItemFromRaw("Rarity: UNIQUE\nThe Saviour\nLegion Sword\nImplicits: 0\nTriggers Level 20 Reflection when Equipped") + build.itemsTab:AddDisplayItem() + build.configTab.input.customMods = "2000% increased Cost of Skills\nSkills Cost Life instead of Mana" + build.configTab:BuildModList() + for _, group in ipairs({ "Slot: Helmet\nArc 20", "Slot: Body Armour\nHeavy Strike 1", "Slot: Boots\nFireball 20" }) do + build.skillsTab:PasteSocketGroup(group .. "/0 1\n") + end + runCallback("OnFrame") + for _, group in ipairs(build.skillsTab.socketGroupList) do + group.includeInFullDPS = group.slot == "Helmet" or group.gemList[1].skillId == "UniqueMirageWarriors" + end + build.buildFlag = true + runCallback("OnFrame") + local calc = build.calcsTab.calcs.getMiscCalculator(build) + local override = { repSlotName = "Ring 1", repItem = new("Item"):Item("Rarity: RARE\nAttack Ring\nIron Ring\nImplicits: 0\nAdds 1000 to 1000 Physical Damage to Attacks") } + local output = calc(override, true) + wipeGlobalCache() + assert.are.same(calc(override, true).SkillDPS, output.SkillDPS) + end) + it("Trigger Mjolner", function() build.itemsTab:CreateDisplayItemFromRaw([[Mjolner Gavel diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 8458c4e468f..af72c3fd0ea 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -1894,6 +1894,10 @@ function calcs.offence(env, actor, activeSkill) end end end + -- A trigger-source probe only needs the cost; skip the damage work for candidates that cannot pay it + if env.triggerSourceCost and actor == env.player and (output[env.triggerSourceCost.stat] or 0) < env.triggerSourceCost.minimum then + return false + end -- Eldritch Battery adds maximum Energy Shield to the Mana available for payable-cost damage. output.ManaCostPayablePool = (output.ManaUnreserved or 0) + (modDB:Flag(nil, "EnergyShieldProtectsMana") and output.EnergyShield or 0) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index c7f75eda6c4..66d8b956352 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -3795,8 +3795,8 @@ function calcs.perform(env, skipEHP) end calcs.triggers(env, env.player) - if not calcs.mirages(env) then - calcs.offence(env, env.player, env.player.mainSkill) + if not calcs.mirages(env) and calcs.offence(env, env.player, env.player.mainSkill) == false then + return false -- Rejected trigger-source probes are never cached end if env.minion then diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index 777890cacb2..b974cf8711d 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -72,15 +72,29 @@ local function defaultComparer(env, uuid, source, triggerRate) end -- Identify the trigger action skill for trigger conditions, take highest Attack Per Second -local function findTriggerSkill(env, skill, source, triggerRate, comparer) +-- sourceCache holds this override's complete candidate calculations and rejected cost probes +local function findTriggerSkill(env, skill, source, triggerRate, comparer, minimumCost, sourceCache) local comparer = comparer or defaultComparer local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, skill, uuid) + local reused = sourceCache and sourceCache[uuid] + if minimumCost and reused and reused.rejectedOutput and (reused.rejectedOutput[minimumCost.stat] or 0) < minimumCost.minimum then + return source, triggerRate, source and cacheSkillUUID(source, env) + end + if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" and not rawequal(reused, GlobalCache.cachedData[env.mode][uuid]) then + local rejectedOutput = calcs.buildActiveSkill(env, env.mode, skill, uuid, nil, sourceCache and minimumCost, sourceCache) + if rejectedOutput then + -- The probe is never published, so drop an older override's entry before a nested consumer reads it + GlobalCache.cachedData[env.mode][uuid] = nil + sourceCache[uuid] = { rejectedOutput = rejectedOutput } + return source, triggerRate, source and cacheSkillUUID(source, env) + elseif sourceCache then + sourceCache[uuid] = GlobalCache.cachedData[env.mode][uuid] + end end - if GlobalCache.cachedData[env.mode][uuid] and comparer(env, uuid, source, triggerRate) and (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] then + local cachedData = GlobalCache.cachedData[env.mode][uuid] + if cachedData and comparer(env, uuid, source, triggerRate) and (not minimumCost or (cachedData[minimumCost.stat] or 0) >= minimumCost.minimum) and (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] then return skill, GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed, uuid end return source, triggerRate, source and cacheSkillUUID(source, env) @@ -403,12 +417,13 @@ local function defaultTriggerHandler(env, config) local sourceWeapon = config.sourceWeapon -- Only attacks using the granting weapon can activate source-weapon triggers local sourceWeaponFlag = sourceWeapon and actor.mainSkill.socketGroup.slot and (actor.mainSkill.socketGroup.slot:match("^Weapon 2") and "weapon2Attack" or "weapon1Attack") + local sourceCache = actor == env.player and not actor.mainSkill.skillFlags.globalTrigger and env.triggerSourceCache or nil -- Find trigger skill and triggered skills if config.triggeredSkillCond or config.triggerSkillCond then for _, skill in ipairs(env.player.activeSkillList) do if config.triggerSkillCond and config.triggerSkillCond(env, skill) and (not sourceWeaponFlag or skill.skillFlags[sourceWeaponFlag]) and (not isTriggered(skill) or actor.mainSkill.skillFlags.globalTrigger or config.allowTriggered) and skill ~= actor.mainSkill then - source, trigRate, uuid = findTriggerSkill(env, skill, source, trigRate, config.comparer) + source, trigRate, uuid = findTriggerSkill(env, skill, source, trigRate, config.comparer, config.minimumCostPerUse, sourceCache) end if config.triggeredSkillCond and config.triggeredSkillCond(env,skill) then t_insert(triggeredSkills, packageSkillDataForSimulation(skill, env)) @@ -1084,28 +1099,20 @@ local configTable = { end end, ["kitava's thirst"] = function(env) - local requiredManaCost = env.player.modDB:Sum("BASE", nil, "KitavaRequiredManaCost") return {triggerChance = env.player.modDB:Sum("BASE", nil, "KitavaTriggerChance"), triggerName = "Kitava's Thirst", - comparer = function(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed - local cachedManaCost = GlobalCache.cachedData[env.mode][uuid].ManaCost - return ( (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) ) and ( (cachedManaCost or 0) >= requiredManaCost ) - end, + -- Paid by a single use; accumulating triggers such as Manaforged Arrows must not declare this + minimumCostPerUse = { stat = "ManaCost", minimum = env.player.modDB:Sum("BASE", nil, "KitavaRequiredManaCost") }, triggerSkillCond = function(env, skill) return true -- Filtering done by skill() in SkillStatMap, comparer and default excludes end} end, ["foulborn kitava's thirst"] = function(env) - local requiredLifeCost = env.player.modDB:Sum("BASE", nil, "FoulbornKitavaRequiredLifeCost") return {triggerChance = env.player.modDB:Sum("BASE", nil, "FoulbornKitavaTriggerChance"), triggerName = "Foulborn Kitava's Thirst", - comparer = function(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed - local cachedLifeCost = GlobalCache.cachedData[env.mode][uuid].LifeCost - return ( (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) ) and ( (cachedLifeCost or 0) >= requiredLifeCost ) - end, + -- Paid by a single use; accumulating triggers such as Manaforged Arrows must not declare this + minimumCostPerUse = { stat = "LifeCost", minimum = env.player.modDB:Sum("BASE", nil, "FoulbornKitavaRequiredLifeCost") }, triggerSkillCond = function(env, skill) return true -- Filtering done by skill() in SkillStatMap, comparer and default excludes diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index dc12041374f..d30a5f777e9 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -99,12 +99,19 @@ function calcs.getMiscCalculator(build) end return function(override, useFullDPS) local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override) + -- Trigger-source calculations shared by this override's main and Full DPS passes + env.triggerSourceCache = { } calcs.perform(env) + local mainUUID = cacheSkillUUID(env.player.mainSkill, env) + local mainEntry = GlobalCache.cachedData["CALCULATOR"][mainUUID] + if mainEntry and mainEntry.Env == env then + env.triggerSourceCache[mainUUID] = mainEntry + end if (useFullDPS ~= false or build.viewMode == "TREE") and usedFullDPS then -- prevent upcoming calculation from using Cached Data and thus forcing it to re-calculate new FullDPS roll-up -- without this, FullDPS increase/decrease when for node/item/gem comparison would be all 0 as it would be comparing -- A with A (due to cache reuse) instead of A with B - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, triggerSourceCache = env.triggerSourceCache }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -141,6 +148,7 @@ end function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) + fullEnv.triggerSourceCache = mode == "CALCULATOR" and (specEnv and specEnv.triggerSourceCache or { }) or nil local usedEnv = nil local fullDPS = { @@ -353,10 +361,16 @@ function calcs.calcFullDPS(build, mode, override, specEnv) return fullDPS end --- Process active skill -function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingFlags) - local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) +-- Process active skill; a trigger-source cost probe returns the partial output of a rejected candidate +function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingFlags, minimumCost, sourceCache) + -- Only a rejected probe's environment can be reused: published results keep referencing theirs + local reusable = sourceCache and sourceCache.rejectedEnv + if reusable then + sourceCache.rejectedEnv = nil + end + local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(env.build, mode, env.override, reusable) fullEnv.buildBreakdown = false + fullEnv.triggerSourceCost = minimumCost -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops @@ -373,7 +387,12 @@ function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingF local activeSkillUUID = cacheSkillUUID(activeSkill, fullEnv) if activeSkillUUID == targetUUID then fullEnv.player.mainSkill = activeSkill - calcs.perform(fullEnv, true) + if calcs.perform(fullEnv, true) == false then + if sourceCache and not fullEnv.minion and not activeSkill.mirage and not activeSkill.skillFlags.globalTrigger then + sourceCache.rejectedEnv = { env = fullEnv, cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, accelerate = { nodeAlloc = not (env.override.addNodes or env.override.removeNodes), requirementsItems = true, requirementsGems = true, skills = true } } + end + return fullEnv.player.output + end return end end