Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions spec/System/TestTriggers_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/Modules/CalcOffence.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/Modules/CalcPerform.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 24 additions & 17 deletions src/Modules/CalcTriggers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions src/Modules/Calcs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading