diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index ae3c0acd93..ddd9d96e36 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -1714,7 +1714,7 @@ function ItemClass:BuildModList() end end for _, mod in ipairs(modLine.modList) do - mod = modLib.setSource(mod, self.modSource) + mod = modLib.withSource(mod, self.modSource) baseList:AddMod(mod) end if modLine.modTags and #modLine.modTags > 0 then diff --git a/src/Classes/ModStore.lua b/src/Classes/ModStore.lua index 92603eb323..27f8e20020 100644 --- a/src/Classes/ModStore.lua +++ b/src/Classes/ModStore.lua @@ -32,6 +32,8 @@ local ModStoreClass = newClass("ModStore", function(self, parent) self.actor = parent and parent.actor or { } self.multipliers = { } self.conditions = { } + -- ModDB/ModList instances participate in actor/parent graphs and are not plain copyTable() targets. + graphNodeTag(self, self._className or "ModStore") end) function ModStoreClass:ScaleAddMod(mod, scale, replace) @@ -45,7 +47,7 @@ function ModStoreClass:ScaleAddMod(mod, scale, replace) if scale == 1 or unscalable then self:AddMod(mod) else - local scaledMod = copyTable(mod) + local scaledMod = type(mod.value) == "table" and copyTableSafe(mod, false) or copyTable(mod) local subMod = scaledMod if type(scaledMod.value) == "table" then if scaledMod.value.mod then @@ -367,7 +369,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) mult = 1 / mult end if type(value) == "table" then - value = copyTable(value) + value = copyTableSafe(value, false) if value.mod then value.mod.value = value.mod.value * mult + (tag.base or 0) if limitTotal then @@ -453,7 +455,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end end if type(value) == "table" then - value = copyTable(value) + value = copyTableSafe(value, false) if value.mod then value.mod.value = value.mod.value * mult + (tag.base or 0) if limitTotal then @@ -502,7 +504,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end end if type(value) == "table" then - value = copyTable(value) + value = copyTableSafe(value, false) if value.mod then value.mod.value = m_ceil(value.mod.value * mult + (tag.base or 0)) if limitTotal then @@ -901,4 +903,4 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end end return value -end \ No newline at end of file +end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index c67b7ac27e..b1fe867429 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -2162,7 +2162,7 @@ function PassiveSpecClass:NodeAdditionOrReplacementFromString(node,sd,replacemen for _, mod in pairs(addition.mods) do if mod.list and not mod.extra then for i, mod in ipairs(mod.list) do - mod = modLib.setSource(mod, "Tree:"..node.id) + mod = modLib.withSource(mod, "Tree:"..node.id) addition.modList:AddMod(mod) end end diff --git a/src/Classes/PassiveTree.lua b/src/Classes/PassiveTree.lua index 6ea7da09a0..110cab50c9 100644 --- a/src/Classes/PassiveTree.lua +++ b/src/Classes/PassiveTree.lua @@ -791,7 +791,7 @@ function PassiveTreeClass:ProcessStats(node, startIndex) local mod = node.mods[i] if mod.list and not mod.extra then for i, mod in ipairs(mod.list) do - mod = modLib.setSource(mod, "Tree:"..node.id) + mod = modLib.withSource(mod, "Tree:"..node.id) node.modList:AddMod(mod) end end diff --git a/src/Data/Skills/sup_str.lua b/src/Data/Skills/sup_str.lua index 604798a2d0..2d4f1ed959 100644 --- a/src/Data/Skills/sup_str.lua +++ b/src/Data/Skills/sup_str.lua @@ -2493,8 +2493,8 @@ skills["AvengingFlame"] = { castTime = 1, preDamageFunc = function(activeSkill, output) local uuid = activeSkill.skillData.triggerSourceUUID - local cache = uuid and (GlobalCache.cachedData["MAIN"][uuid] or GlobalCache.cachedData["CALCS"][uuid]) - local totemLife = cache and cache.Env.player.output.TotemLife or 0 + local cache = uuid and (getGlobalCacheTable("MAIN")[uuid] or getGlobalCacheTable("CALCS")[uuid]) + local totemLife = cache and cache.Output.TotemLife or 0 local add = totemLife * activeSkill.skillData.lifeDealtAsFire / 100 activeSkill.skillData.FireMax = (activeSkill.skillData.FireMax or 0) + add @@ -5575,4 +5575,4 @@ skills["SupportExpertRetaliation"] = { [39] = { 107, 53, levelRequirement = 99, manaMultiplier = 30, statInterpolation = { 1, 1, }, }, [40] = { 108, 54, levelRequirement = 100, manaMultiplier = 30, statInterpolation = { 1, 1, }, }, }, -} \ No newline at end of file +} diff --git a/src/Export/Skills/sup_str.txt b/src/Export/Skills/sup_str.txt index 5bbb9a7b49..7ed4f52ca5 100644 --- a/src/Export/Skills/sup_str.txt +++ b/src/Export/Skills/sup_str.txt @@ -338,8 +338,8 @@ local skills, mod, flag, skill = ... #skill AvengingFlame preDamageFunc = function(activeSkill, output) local uuid = activeSkill.skillData.triggerSourceUUID - local cache = uuid and (GlobalCache.cachedData["MAIN"][uuid] or GlobalCache.cachedData["CALCS"][uuid]) - local totemLife = cache and cache.Env.player.output.TotemLife or 0 + local cache = uuid and (getGlobalCacheTable("MAIN")[uuid] or getGlobalCacheTable("CALCS")[uuid]) + local totemLife = cache and cache.Output.TotemLife or 0 local add = totemLife * activeSkill.skillData.lifeDealtAsFire / 100 activeSkill.skillData.FireMax = (activeSkill.skillData.FireMax or 0) + add @@ -796,4 +796,4 @@ local skills, mod, flag, skill = ... mod("CooldownRecovery", "MORE", nil), }, }, -#mods \ No newline at end of file +#mods diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index 582cd37691..063b011a69 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -31,9 +31,8 @@ local function mergeLevelMod(modList, mod, value) elseif value then local newMod = copyTable(mod, true) if type(newMod.value) == "table" then - newMod.value = copyTable(newMod.value, true) + newMod.value = copyTableSafe(newMod.value, false) if newMod.value.mod then - newMod.value.mod = copyTable(newMod.value.mod, true) newMod.value.mod.value = value else newMod.value.value = value @@ -80,7 +79,8 @@ end -- Create an active skill using the given active gem and list of support gems -- It will determine the base flag set, and check which of the support gems can support this skill function calcs.createActiveSkill(activeEffect, supportList, actor, socketGroup, summonSkill) - local activeSkill = { + -- Active skills retain live links to actor/support/socket/minion state and are graph objects. + local activeSkill = graphNodeTag({ activeEffect = activeEffect, supportList = supportList, actor = actor, @@ -88,7 +88,7 @@ function calcs.createActiveSkill(activeEffect, supportList, actor, socketGroup, socketGroup = socketGroup, skillData = { }, buffList = { }, - } + }, "ActiveSkill") local activeGrantedEffect = activeEffect.grantedEffect @@ -160,25 +160,337 @@ function calcs.createActiveSkill(activeEffect, supportList, actor, socketGroup, return activeSkill end --- Copy an Active Skill -function calcs.copyActiveSkill(env, mode, skill) - local activeEffect = { - grantedEffect = skill.activeEffect.grantedEffect, - level = skill.activeEffect.level, - quality = skill.activeEffect.quality +function calcs.getActiveEffectSourceValue(activeEffect, key, fallback) + if activeEffect.snapshotState and activeEffect.snapshotState[key] ~= nil then + return activeEffect.snapshotState[key] + end + if activeEffect.srcInstance and activeEffect.srcInstance[key] ~= nil then + return activeEffect.srcInstance[key] + end + if activeEffect[key] ~= nil then + return activeEffect[key] + end + return fallback +end + +function calcs.getActiveEffectSelection(activeEffect, calcKey, mainKey, useCalcSelection, fallback) + if activeEffect.snapshotState and activeEffect.snapshotState[mainKey] ~= nil then + return activeEffect.snapshotState[mainKey] + end + if activeEffect.srcInstance then + local key = useCalcSelection and calcKey or mainKey + if activeEffect.srcInstance[key] ~= nil then + return activeEffect.srcInstance[key] + end + end + return fallback +end + +local function setActiveEffectSelection(activeEffect, calcKey, mainKey, useCalcSelection, value) + if activeEffect.snapshotState then + activeEffect.snapshotState[mainKey] = value + elseif activeEffect.srcInstance then + activeEffect.srcInstance[useCalcSelection and calcKey or mainKey] = value + end + return value +end + +local function clearActiveEffectSelection(activeEffect, calcKey, mainKey) + if activeEffect.snapshotState then + activeEffect.snapshotState[mainKey] = nil + elseif activeEffect.srcInstance then + activeEffect.srcInstance[calcKey] = nil + activeEffect.srcInstance[mainKey] = nil + end +end + +local function findListIndex(list, target) + if not list or not target then + return + end + for index, value in ipairs(list) do + if value == target then + return index + end + end +end + +local function snapshotGemInstance(gemInstance) + if not gemInstance then + return nil + end + return { + gemId = gemInstance.gemId or (gemInstance.gemData and gemInstance.gemData.id), + skillId = gemInstance.skillId or (gemInstance.grantedEffect and gemInstance.grantedEffect.id) or (gemInstance.gemData and gemInstance.gemData.grantedEffectId), + level = gemInstance.level, + quality = gemInstance.quality, + qualityId = gemInstance.qualityId, + enabled = gemInstance.enabled, + } +end + +local function gemInstanceMatchesSnapshot(gemInstance, snapshot) + if not snapshot then + return gemInstance == nil + end + if not gemInstance then + return false + end + local skillId = gemInstance.skillId or (gemInstance.grantedEffect and gemInstance.grantedEffect.id) or (gemInstance.gemData and gemInstance.gemData.grantedEffectId) + local gemId = gemInstance.gemId or (gemInstance.gemData and gemInstance.gemData.id) + return (not snapshot.gemId or snapshot.gemId == gemId) + and (not snapshot.skillId or snapshot.skillId == skillId) + and snapshot.level == gemInstance.level + and snapshot.quality == gemInstance.quality + and snapshot.qualityId == gemInstance.qualityId + and snapshot.enabled == gemInstance.enabled +end + +local function snapshotSupportEffect(supportEffect) + return { + grantedEffectId = supportEffect.grantedEffect.id, + level = supportEffect.level, + quality = supportEffect.quality, + qualityId = supportEffect.qualityId, + fromItem = supportEffect.grantedEffect.fromItem or (supportEffect.srcInstance and supportEffect.srcInstance.fromItem), + gem = snapshotGemInstance(supportEffect.srcInstance), } +end + +local function supportEffectMatchesSnapshot(supportEffect, snapshot) + if not supportEffect or not snapshot then + return supportEffect == nil and snapshot == nil + end + return supportEffect.grantedEffect.id == snapshot.grantedEffectId + and supportEffect.level == snapshot.level + and supportEffect.quality == snapshot.quality + and supportEffect.qualityId == snapshot.qualityId + and (supportEffect.grantedEffect.fromItem or (supportEffect.srcInstance and supportEffect.srcInstance.fromItem)) == snapshot.fromItem + and gemInstanceMatchesSnapshot(supportEffect.srcInstance, snapshot.gem) +end + +local function supportListMatchesSnapshot(supportList, snapshotList) + if not snapshotList then + return true + end + if #supportList ~= #snapshotList then + return false + end + for index, supportSnapshot in ipairs(snapshotList) do + if not supportEffectMatchesSnapshot(supportList[index], supportSnapshot) then + return false + end + end + return true +end - if skill.activeEffect.srcInstance then - activeEffect.level = skill.activeEffect.srcInstance.level - activeEffect.quality = skill.activeEffect.srcInstance.quality - activeEffect.qualityId = skill.activeEffect.srcInstance.qualityId - activeEffect.srcInstance = skill.activeEffect.srcInstance - activeEffect.gemData = skill.activeEffect.srcInstance.gemData +local function snapshotSocketGroup(socketGroup) + if not socketGroup then + return nil + end + local snapshot = { + slot = socketGroup.slot, + label = socketGroup.label, + source = socketGroup.source, + noSupports = socketGroup.noSupports, + gems = { }, + } + for index, gemInstance in ipairs(socketGroup.gemList or { }) do + snapshot.gems[index] = snapshotGemInstance(gemInstance) end + return snapshot +end - local newSkill = calcs.createActiveSkill(activeEffect, skill.supportList, skill.actor, skill.socketGroup, skill.summonSkill) - local newEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) - calcs.buildActiveSkillModList(newEnv, newSkill) +local function socketGroupMatchesSnapshot(socketGroup, snapshot) + if not snapshot then + return socketGroup == nil + end + if not socketGroup then + return false + end + if socketGroup.slot ~= snapshot.slot or socketGroup.label ~= snapshot.label or socketGroup.source ~= snapshot.source or socketGroup.noSupports ~= snapshot.noSupports then + return false + end + if #socketGroup.gemList ~= #snapshot.gems then + return false + end + for index, gemSnapshot in ipairs(snapshot.gems) do + if not gemInstanceMatchesSnapshot(socketGroup.gemList[index], gemSnapshot) then + return false + end + end + return true +end + +local function findGrantedEffectIndex(grantedEffectList, grantedEffect) + if not grantedEffectList or not grantedEffect then + return nil + end + for index, effect in ipairs(grantedEffectList) do + if effect == grantedEffect or effect.id == grantedEffect.id then + return index + end + end +end + +-- Snapshot locators only store scalar identifiers; live actor/support/socket graphs are resolved later. +local function snapshotSkillLocator(skill) + local activeEffect = skill.activeEffect + local srcInstance = activeEffect.srcInstance + return { + grantedEffectId = activeEffect.grantedEffect.id, + level = calcs.getActiveEffectSourceValue(activeEffect, "level", activeEffect.level), + quality = calcs.getActiveEffectSourceValue(activeEffect, "quality", activeEffect.quality), + qualityId = calcs.getActiveEffectSourceValue(activeEffect, "qualityId", activeEffect.qualityId), + socketGroup = snapshotSocketGroup(skill.socketGroup), + sourceGem = { + gemIndex = skill.socketGroup and findListIndex(skill.socketGroup.gemList, srcInstance) or nil, + grantedEffectIndex = srcInstance and srcInstance.gemData and findGrantedEffectIndex(srcInstance.gemData.grantedEffectList, activeEffect.grantedEffect) or nil, + gem = snapshotGemInstance(srcInstance), + }, + supportList = (function() + local supportList = { } + for index, supportEffect in ipairs(skill.supportList or { }) do + supportList[index] = snapshotSupportEffect(supportEffect) + end + return supportList + end)(), + } +end + +local function skillMatchesSnapshot(skill, snapshot, strict) + local activeEffect = skill.activeEffect + local sourceGem = snapshot.sourceGem + if activeEffect.grantedEffect.id ~= snapshot.grantedEffectId then + return false + end + if calcs.getActiveEffectSourceValue(activeEffect, "level", activeEffect.level) ~= snapshot.level + or calcs.getActiveEffectSourceValue(activeEffect, "quality", activeEffect.quality) ~= snapshot.quality + or calcs.getActiveEffectSourceValue(activeEffect, "qualityId", activeEffect.qualityId) ~= snapshot.qualityId then + return false + end + if not socketGroupMatchesSnapshot(skill.socketGroup, snapshot.socketGroup) then + return false + end + if strict and sourceGem then + if sourceGem.gemIndex and sourceGem.gemIndex ~= (skill.socketGroup and findListIndex(skill.socketGroup.gemList, activeEffect.srcInstance) or nil) then + return false + end + if sourceGem.grantedEffectIndex and sourceGem.grantedEffectIndex ~= (activeEffect.srcInstance and activeEffect.srcInstance.gemData and findGrantedEffectIndex(activeEffect.srcInstance.gemData.grantedEffectList, activeEffect.grantedEffect) or nil) then + return false + end + if not gemInstanceMatchesSnapshot(activeEffect.srcInstance, sourceGem.gem) then + return false + end + if not supportListMatchesSnapshot(skill.supportList or { }, snapshot.supportList) then + return false + end + end + return true +end + +local function findSkillTemplate(skillList, snapshot) + for _, skill in ipairs(skillList or { }) do + if skillMatchesSnapshot(skill, snapshot, true) then + return skill + end + end + for _, skill in ipairs(skillList or { }) do + if skillMatchesSnapshot(skill, snapshot, false) then + return skill + end + end +end + +local function resolveSkillTemplate(env, snapshot, actor) + if snapshot.summonSkill then + local summonSkill = resolveSkillTemplate(env, snapshot.summonSkill, env.player) + if not summonSkill or not summonSkill.minion or not summonSkill.minion.activeSkillList then + return nil + end + if snapshot.minionSkillIndex and summonSkill.minion.activeSkillList[snapshot.minionSkillIndex] and summonSkill.minion.activeSkillList[snapshot.minionSkillIndex].activeEffect.grantedEffect.id == snapshot.grantedEffectId then + return summonSkill.minion.activeSkillList[snapshot.minionSkillIndex] + end + return findSkillTemplate(summonSkill.minion.activeSkillList, snapshot) + end + return findSkillTemplate((actor and actor.activeSkillList) or env.player.activeSkillList, snapshot) +end + +local function cloneActiveEffect(template, snapshot) + local activeEffect = { } + for key, value in pairs(template) do + if type(value) ~= "table" then + activeEffect[key] = value + end + end + activeEffect.grantedEffect = template.grantedEffect + activeEffect.srcInstance = template.srcInstance + activeEffect.gemData = template.gemData + if template.gemCfg then + activeEffect.gemCfg = copyTable(template.gemCfg, true) + end + if template.gemPropertyInfo then + activeEffect.gemPropertyInfo = copyTable(template.gemPropertyInfo, true) + end + activeEffect.level = snapshot.level or template.level + activeEffect.quality = snapshot.quality or template.quality + activeEffect.qualityId = snapshot.qualityId or template.qualityId + + local snapshotState = { + level = activeEffect.level, + quality = activeEffect.quality, + qualityId = activeEffect.qualityId, + } + for key, value in pairs(snapshot.selection or { }) do + snapshotState[key] = value + end + for key, value in pairs(snapshot.trigger or { }) do + snapshotState[key] = value + end + activeEffect.snapshotState = snapshotState + return activeEffect +end + +function calcs.snapshotActiveSkill(skill) + local srcInstance = skill.activeEffect.srcInstance + local selection = { + skillPart = skill.skillPart or (srcInstance and (srcInstance.skillPartCalcs or srcInstance.skillPart)), + skillMineCount = skill.activeMineCount or (srcInstance and (srcInstance.skillMineCountCalcs or srcInstance.skillMineCount)), + skillStageCount = (srcInstance and (srcInstance.skillStageCountCalcs or srcInstance.skillStageCount)) or (skill.activeStageCount and skill.activeStageCount + 1), + skillMinion = skill.minion and skill.minion.type or (srcInstance and (srcInstance.skillMinionCalcs or srcInstance.skillMinion)), + skillMinionItemSet = srcInstance and (srcInstance.skillMinionItemSetCalcs or srcInstance.skillMinionItemSet), + skillMinionSkill = (srcInstance and (srcInstance.skillMinionSkillCalcs or srcInstance.skillMinionSkill)) or (skill.minion and skill.minion.activeSkillList and findListIndex(skill.minion.activeSkillList, skill.minion.mainSkill)), + } + local snapshot = snapshotSkillLocator(skill) + snapshot.selection = selection + snapshot.trigger = { + triggered = calcs.getActiveEffectSourceValue(skill.activeEffect, "triggered"), + triggerChance = calcs.getActiveEffectSourceValue(skill.activeEffect, "triggerChance"), + fromItem = calcs.getActiveEffectSourceValue(skill.activeEffect, "fromItem", skill.activeEffect.grantedEffect.fromItem), + noSupports = calcs.getActiveEffectSourceValue(skill.activeEffect, "noSupports"), + } + if skill.skillFlags and skill.skillFlags.minionSkill and skill.summonSkill then + snapshot.summonSkill = snapshotSkillLocator(skill.summonSkill) + snapshot.minionSkillIndex = findListIndex(skill.summonSkill.minion and skill.summonSkill.minion.activeSkillList, skill) + end + return snapshot +end + +-- Rebuild a calc-local skill using env-local graph objects resolved from the snapshot. +function calcs.rebuildSkillFromSnapshot(env, snapshot, actor) + local template = resolveSkillTemplate(env, snapshot, actor) + if not template then + return nil + end + + local supportList = { } + for index, supportEffect in ipairs(template.supportList or { }) do + supportList[index] = supportEffect + end + + local newSkill = calcs.createActiveSkill(cloneActiveEffect(template.activeEffect, snapshot), supportList, template.actor, template.socketGroup, template.summonSkill) + newSkill.slotName = template.slotName + calcs.buildActiveSkillModList(env, newSkill) newSkill.skillModList = new("ModList", newSkill.baseSkillModList) if newSkill.minion then newSkill.minion.modDB = new("ModDB") @@ -186,6 +498,14 @@ function calcs.copyActiveSkill(env, mode, skill) calcs.createMinionSkills(env, newSkill) newSkill.skillPartName = newSkill.minion.mainSkill.activeEffect.grantedEffect.name end + return newSkill +end + +-- Deprecated: use snapshotActiveSkill()/rebuildSkillFromSnapshot() for calc-local skill clones. +function calcs.copyActiveSkill(env, mode, skill) + local snapshot = calcs.snapshotActiveSkill(skill) + local newEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) + local newSkill = calcs.rebuildSkillFromSnapshot(newEnv, snapshot, newEnv.player) return newSkill, newEnv end @@ -245,13 +565,8 @@ function calcs.buildActiveSkillModList(env, activeSkill) -- Handle multipart skills local activeGemParts = activeGrantedEffect.parts if activeGemParts and #activeGemParts > 1 then - if env.mode == "CALCS" and activeSkill == env.player.mainSkill then - activeEffect.srcInstance.skillPartCalcs = m_min(#activeGemParts, activeEffect.srcInstance.skillPartCalcs or 1) - activeSkill.skillPart = activeEffect.srcInstance.skillPartCalcs - else - activeEffect.srcInstance.skillPart = m_min(#activeGemParts, activeEffect.srcInstance.skillPart or 1) - activeSkill.skillPart = activeEffect.srcInstance.skillPart - end + local useCalcSelection = env.mode == "CALCS" and activeSkill == env.player.mainSkill + activeSkill.skillPart = setActiveEffectSelection(activeEffect, "skillPartCalcs", "skillPart", useCalcSelection, m_min(#activeGemParts, calcs.getActiveEffectSelection(activeEffect, "skillPartCalcs", "skillPart", useCalcSelection, 1) or 1)) local part = activeGemParts[activeSkill.skillPart] for k, v in pairs(part) do if v == true then @@ -263,8 +578,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) activeSkill.skillPartName = part.name skillFlags.multiPart = #activeGemParts > 1 elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillPart = nil - activeEffect.srcInstance.skillPartCalcs = nil + clearActiveEffectSelection(activeEffect, "skillPartCalcs", "skillPart") end if (skillTypes[SkillType.RequiresShield] or skillFlags.shieldAttack) and not activeSkill.summonSkill and (not activeSkill.actor.itemList["Weapon 2"] or activeSkill.actor.itemList["Weapon 2"].type ~= "Shield") then @@ -483,7 +797,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) end -- Mods which apply curses are not disabled by Gruthkul's Pelt - local curseApplicationSkill = activeSkill.socketGroup and activeSkill.socketGroup.sourceItem ~= nil and activeSkill.skillFlags.curse and activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.noSupports and activeSkill.activeEffect.srcInstance.triggered + local curseApplicationSkill = activeSkill.socketGroup and activeSkill.socketGroup.sourceItem ~= nil and activeSkill.skillFlags.curse and calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "noSupports") and calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "triggered") if skillModList:Flag(activeSkill.skillCfg, "DisableSkill") and not (skillModList:Flag(activeSkill.skillCfg, "EnableSkill") or (curseApplicationSkill and skillModList:Flag(nil, "ForceEnableCurseApplication"))) then skillFlags.disable = true activeSkill.disableReason = "Skills of this type are disabled" @@ -531,8 +845,8 @@ function calcs.buildActiveSkillModList(env, activeSkill) end -- Apply gem/quality modifiers from support gems - skillModList:NewMod("GemLevel", "BASE", activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.level or activeSkill.activeEffect.level, "Max Level") - skillModList:NewMod("GemQuality", "BASE", activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.quality or activeSkill.activeEffect.quality, "Max Quality") + skillModList:NewMod("GemLevel", "BASE", calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "level", activeSkill.activeEffect.level), "Max Level") + skillModList:NewMod("GemQuality", "BASE", calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "quality", activeSkill.activeEffect.quality), "Max Quality") for _, supportProperty in ipairs(skillModList:Tabulate("LIST", activeSkill.skillCfg, "SupportedGemProperty")) do local value = supportProperty.value if value.keyword == "grants_active_skill" and activeSkill.activeEffect.gemData and not activeSkill.activeEffect.gemData.tags.support then @@ -593,14 +907,13 @@ function calcs.buildActiveSkillModList(env, activeSkill) -- Add active mine multiplier if skillFlags.mine then - activeSkill.activeMineCount = (env.mode == "CALCS" and activeEffect.srcInstance.skillMineCountCalcs) or (env.mode ~= "CALCS" and activeEffect.srcInstance.skillMineCount) + activeSkill.activeMineCount = calcs.getActiveEffectSelection(activeEffect, "skillMineCountCalcs", "skillMineCount", env.mode == "CALCS") if activeSkill.activeMineCount and activeSkill.activeMineCount > 0 then skillModList:NewMod("Multiplier:ActiveMineCount", "BASE", activeSkill.activeMineCount, "Base") env.enemy.modDB.multipliers["ActiveMineCount"] = m_max(activeSkill.activeMineCount or 0, env.enemy.modDB.multipliers["ActiveMineCount"] or 0) end elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillMineCountCalcs = nil - activeEffect.srcInstance.skillMineCount = nil + clearActiveEffectSelection(activeEffect, "skillMineCountCalcs", "skillMineCount") end @@ -617,7 +930,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) if skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MaxStages") > 0 then skillFlags.multiStage = true - activeSkill.activeStageCount = m_max((env.mode == "CALCS" and activeEffect.srcInstance.skillStageCountCalcs) or (env.mode ~= "CALCS" and activeEffect.srcInstance.skillStageCount) or 1, 1 + skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MinimumStage")) + activeSkill.activeStageCount = m_max(calcs.getActiveEffectSelection(activeEffect, "skillStageCountCalcs", "skillStageCount", env.mode == "CALCS", 1) or 1, 1 + skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MinimumStage")) local limit = skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MaxStages") if limit > 0 then if activeSkill.activeStageCount and activeSkill.activeStageCount > 0 then @@ -627,8 +940,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) end end elseif noPotentialStage and activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillStageCountCalcs = nil - activeEffect.srcInstance.skillStageCount = nil + clearActiveEffectSelection(activeEffect, "skillStageCountCalcs", "skillStageCount") end -- Extract skill data @@ -665,15 +977,10 @@ function calcs.buildActiveSkillModList(env, activeSkill) activeSkill.minionList = minionList if minionList[1] and not activeSkill.actor.minionData then local minionType - if env.mode == "CALCS" and activeSkill == env.player.mainSkill then - local index = isValueInArray(minionList, activeEffect.srcInstance.skillMinionCalcs) or 1 - minionType = minionList[index] - activeEffect.srcInstance.skillMinionCalcs = minionType - else - local index = isValueInArray(minionList, activeEffect.srcInstance.skillMinion) or 1 - minionType = minionList[index] - activeEffect.srcInstance.skillMinion = minionType - end + local useCalcSelection = env.mode == "CALCS" and activeSkill == env.player.mainSkill + local index = isValueInArray(minionList, calcs.getActiveEffectSelection(activeEffect, "skillMinionCalcs", "skillMinion", useCalcSelection)) or 1 + minionType = minionList[index] + setActiveEffectSelection(activeEffect, "skillMinionCalcs", "skillMinion", useCalcSelection, minionType) if minionType then local minion = { } activeSkill.minion = minion @@ -707,20 +1014,15 @@ function calcs.buildActiveSkillModList(env, activeSkill) damage = damage * attackTime end if activeGrantedEffect.minionHasItemSet then - if env.mode == "CALCS" and activeSkill == env.player.mainSkill then - if not env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSetCalcs] then - activeEffect.srcInstance.skillMinionItemSetCalcs = env.build.itemsTab.itemSetOrderList[1] - end - minion.itemSet = env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSetCalcs] - else - if not env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSet] then - activeEffect.srcInstance.skillMinionItemSet = env.build.itemsTab.itemSetOrderList[1] - end - minion.itemSet = env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSet] + local useCalcSelection = env.mode == "CALCS" and activeSkill == env.player.mainSkill + local itemSetId = calcs.getActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet", useCalcSelection) + if not env.build.itemsTab.itemSets[itemSetId] then + itemSetId = env.build.itemsTab.itemSetOrderList[1] + setActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet", useCalcSelection, itemSetId) end + minion.itemSet = env.build.itemsTab.itemSets[itemSetId] elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillMinionItemSetCalcs = nil - activeEffect.srcInstance.skillMinionItemSet = nil + clearActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet") end if (activeSkill.skillData.minionUseBowAndQuiver and env.player.weaponData1.type == "Bow") or activeSkill.skillData.minionUseMainHandWeapon then minion.weaponData1 = env.player.weaponData1 @@ -761,12 +1063,9 @@ function calcs.buildActiveSkillModList(env, activeSkill) end end elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillMinionCalcs = nil - activeEffect.srcInstance.skillMinion = nil - activeEffect.srcInstance.skillMinionItemSetCalcs = nil - activeEffect.srcInstance.skillMinionItemSet = nil - activeEffect.srcInstance.skillMinionSkill = nil - activeEffect.srcInstance.skillMinionSkillCalcs = nil + clearActiveEffectSelection(activeEffect, "skillMinionCalcs", "skillMinion") + clearActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet") + clearActiveEffectSelection(activeEffect, "skillMinionSkillCalcs", "skillMinionSkill") end -- Separate global effect modifiers (mods that can affect defensive stats or other skills) @@ -820,7 +1119,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) for d = 1, #modList do local destMod = modList[d] if modLib.compareModParams(skillModList[i], destMod) and (destMod.type == "BASE" or destMod.type == "INC") then - destMod = copyTable(destMod) + destMod = type(destMod.value) == "table" and copyTableSafe(destMod, false) or copyTable(destMod) destMod.value = destMod.value + skillModList[i].value modList[d] = destMod match = true @@ -889,14 +1188,8 @@ function calcs.createMinionSkills(env, activeSkill) t_insert(minion.activeSkillList, minionSkill) end local skillIndex - if env.mode == "CALCS" then - skillIndex = m_max(m_min(activeEffect.srcInstance.skillMinionSkillCalcs or 1, #minion.activeSkillList), 1) - activeEffect.srcInstance.skillMinionSkillCalcs = skillIndex - else - skillIndex = m_max(m_min(activeEffect.srcInstance.skillMinionSkill or 1, #minion.activeSkillList), 1) - if env.mode == "MAIN" then - activeEffect.srcInstance.skillMinionSkill = skillIndex - end - end + local useCalcSelection = env.mode == "CALCS" + skillIndex = m_max(m_min(calcs.getActiveEffectSelection(activeEffect, "skillMinionSkillCalcs", "skillMinionSkill", useCalcSelection, 1) or 1, #minion.activeSkillList), 1) + setActiveEffectSelection(activeEffect, "skillMinionSkillCalcs", "skillMinionSkill", useCalcSelection, skillIndex) minion.mainSkill = minion.activeSkillList[skillIndex] end diff --git a/src/Modules/CalcMirages.lua b/src/Modules/CalcMirages.lua index 9d14cf1427..1c4365cfdc 100644 --- a/src/Modules/CalcMirages.lua +++ b/src/Modules/CalcMirages.lua @@ -35,7 +35,13 @@ local function calculateMirage(env, config) end if mirageSkill then - local newSkill, newEnv = calcs.copyActiveSkill(env, "CALCULATOR", mirageSkill) + local snapshot = calcs.snapshotActiveSkill(mirageSkill) + local newEnv, _, _, _ = calcs.initEnv(env.build, "CALCULATOR", env.override, { cacheGeneration = env.cacheGeneration, recursionGuards = env.recursionGuards }) + local newSkill = calcs.rebuildSkillFromSnapshot(newEnv, snapshot, newEnv.player) + if not newSkill then + config.mirageSkillNotFoundFunc(env, config) + return not config.calcMainSkillOffence + end newSkill.skillCfg.skillCond["usedByMirage"] = true newEnv.limitedSkills = newEnv.limitedSkills or {} newEnv.limitedSkills[cacheSkillUUID(newSkill, newEnv)] = true @@ -119,18 +125,14 @@ function calcs.mirages(env) config = { compareFunc = function(skill, env, config, mirageSkill) if skill ~= env.player.mainSkill and skill.skillTypes[SkillType.Attack] and not skill.skillTypes[SkillType.Totem] and not skill.skillTypes[SkillType.SummonsTotem] and band(skill.skillCfg.flags, bor(ModFlag.Sword, ModFlag.Weapon1H)) == bor(ModFlag.Sword, ModFlag.Weapon1H) and not skill.skillCfg.skillCond["usedByMirage"] then - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end - - if GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].CritChance and GlobalCache.cachedData[env.mode][uuid].CritChance > 0 then + local entry = calcs.getCachedSkillEntry(env, skill) + if entry and entry.CritChance and entry.CritChance > 0 then if not mirageSkill then - usedSkillBestDps = GlobalCache.cachedData[env.mode][uuid].TotalDPS - return GlobalCache.cachedData[env.mode][uuid].ActiveSkill - elseif GlobalCache.cachedData[env.mode][uuid].TotalDPS > usedSkillBestDps then - usedSkillBestDps = GlobalCache.cachedData[env.mode][uuid].TotalDPS - return GlobalCache.cachedData[env.mode][uuid].ActiveSkill + usedSkillBestDps = entry.TotalDPS + return skill + elseif entry.TotalDPS > usedSkillBestDps then + usedSkillBestDps = entry.TotalDPS + return skill end end end @@ -192,15 +194,11 @@ function calcs.mirages(env) local skillTypeMatch = (skill.skillTypes[SkillType.Slam] or skill.skillTypes[SkillType.Melee]) and skill.skillTypes[SkillType.Attack] local skillTypeExcludes = skill.skillTypes[SkillType.Vaal] or skill.skillTypes[SkillType.Totem] or skill.skillTypes[SkillType.SummonsTotem] if skill ~= env.player.mainSkill and not isTriggered(skill) and not isDisabled and skillTypeMatch and not skillTypeExcludes and not skill.skillCfg.skillCond["usedByMirage"] then - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end - - if not mirageSkill or (GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].TotalDPS > usedSkillBestDps) then - usedSkillBestDps = GlobalCache.cachedData[env.mode][uuid].TotalDPS - EffectiveSourceRate = GlobalCache.cachedData[env.mode][uuid].Speed - return GlobalCache.cachedData[env.mode][uuid].ActiveSkill + local entry = calcs.getCachedSkillEntry(env, skill) + if entry and (not mirageSkill or entry.TotalDPS > usedSkillBestDps) then + usedSkillBestDps = entry.TotalDPS + EffectiveSourceRate = entry.Speed + return skill end end return mirageSkill @@ -367,10 +365,8 @@ function calcs.mirages(env) env.player.mainSkill.skillCfg.skillCond["usedByMirage"] = true env.player.mainSkill.skillTypes[SkillType.OtherThingUsesSkill] = true - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, env.player.mainSkill, uuid, {uuid}) - end - local mainSkillOutputCache = GlobalCache.cachedData[env.mode][uuid].Env.player.output + local mainSkillCacheEntry = calcs.getCachedSkillEntry(env, env.player.mainSkill, {uuid}) + local mainSkillOutputCache = mainSkillCacheEntry and mainSkillCacheEntry.Output or {} -- Find the active General's Cry gem to get active properties for _, skill in ipairs(env.player.activeSkillList) do @@ -393,7 +389,7 @@ function calcs.mirages(env) if env.player.mainSkill.skillTypes[SkillType.Channel] then mirageSpawnTime = mirageSpawnTime + 1 else - mirageSpawnTime = mirageSpawnTime + (mainSkillOutputCache.HitTime or mainSkillOutputCache.Time) + mirageSpawnTime = mirageSpawnTime + (mainSkillOutputCache.HitTime or mainSkillOutputCache.Time or 0) env.player.mainSkill.skillData.timeOverride = 1 end @@ -434,4 +430,4 @@ function calcs.mirages(env) end return calculateMirage(env, config) -end \ No newline at end of file +end diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 50041da89b..01e61c03ca 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -19,6 +19,8 @@ local m_huge = math.huge local bor = bit.bor local band = bit.band local bnot = bit.bnot +local wipeTable = wipeTable +local graphNodeTag = graphNodeTag --- getCachedOutputValue --- retrieves a value specified by key from a cached version of skill @@ -28,14 +30,11 @@ local bnot = bit.bnot --- @param ... table keys to values to be returned (Note: EmmyLua does not natively support documenting variadic parameters) --- @return table unpacked table containing the desired values local function getCachedOutputValue(env, activeSkill, ...) - local uuid = cacheSkillUUID(activeSkill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, activeSkill, uuid, {uuid}) - end + local entry = calcs.getCachedSkillEntry(env, activeSkill, { cacheSkillUUID(activeSkill, env) }) local tempValues = {} for i,v in ipairs({...}) do - tempValues[i] = GlobalCache.cachedData[env.mode][uuid].Env.player.output[v] + tempValues[i] = entry and entry.Output[v] or 0 end return unpack(tempValues) end @@ -533,7 +532,7 @@ function doActorLifeManaReservation(actor, addAura) end if addAura then for _, value in ipairs(modDB:List(nil, "GrantReserved"..pool.."AsAura")) do - local auraMod = copyTable(value.mod) + local auraMod = type(value.mod.value) == "table" and copyTableSafe(value.mod, false) or copyTable(value.mod) auraMod.value = m_floor(auraMod.value * m_min(reserved, max)) modDB:NewMod("ExtraAura", "LIST", { mod = auraMod }) end @@ -584,7 +583,7 @@ local function applyEnemyModifiers(actor, clearCache) local mod = value.value and value.value.mod if mod and not cache[mod] then local source = mod.source or value.mod.source - enemyDB:AddMod(modLib.setSource(mod, source)) + enemyDB:AddMod(modLib.withSource(mod, source)) cache[mod] = true end end @@ -1065,6 +1064,26 @@ function calcs.actionSpeedMod(actor) return actionSpeedMod end +local function resetReusableModList(modList, parent) + wipeTable(modList) + modList.parent = parent or false + modList.actor = parent and parent.actor or { } + modList.multipliers = { } + modList.conditions = { } + graphNodeTag(modList, modList._className or "ModList") + return modList +end + +local function resetReusableModDB(modDB, parent, actor) + wipeTable(modDB.mods) + modDB.parent = parent or false + modDB.actor = actor or (parent and parent.actor) or modDB.actor or { } + modDB.multipliers = { } + modDB.conditions = { } + graphNodeTag(modDB, modDB._className or "ModDB") + return modDB +end + -- Finalises the environment and performs the stat calculations: -- 1. Merges keystone modifiers -- 2. Initialises minion skills @@ -1077,20 +1096,52 @@ end -- 8. Processes buffs and debuffs -- 9. Processes charges and misc buffs (doActorCharges, doActorMisc) -- 10. Calculates defence and offence stats (calcs.defence, calcs.offence) -function calcs.perform(env, skipEHP) - local modDB = env.modDB - local enemyDB = env.enemyDB +function calcs.performActorPrepass(env, options) + options = options or { } + local prepassState = { + partyTabEnableExportBuffs = env.build.partyTab.enableExportBuffs and env.mode ~= "CALCULATOR", + } + env.partyMembers = env.build.partyTab.actor + env.player.partyMembers = env.partyMembers - -- Merge keystone modifiers + -- Merge keystone modifiers once per actor-global pass so the skill-local + -- phase can reuse the resulting DB baseline. env.keystonesAdded = { } modLib.mergeKeystones(env, env.modDB) + if options.snapshotDBs then + prepassState.cachedPlayerDB, prepassState.cachedEnemyDB, prepassState.cachedMinionDB = specCopy(env) + prepassState.cachedPlayerDB.parent = env.modDB.parent + prepassState.cachedEnemyDB.parent = env.enemyDB.parent + if prepassState.cachedMinionDB and env.minion and env.minion.modDB then + prepassState.cachedMinionDB.parent = env.minion.modDB.parent + end + end + env.performActorPrepassState = prepassState + return prepassState +end + +function calcs.performSkillPass(env, skipEHP, prepassState) + local modDB = env.modDB + local enemyDB = env.enemyDB + prepassState = prepassState or env.performActorPrepassState + env.partyMembers = env.partyMembers or env.build.partyTab.actor + env.player.partyMembers = env.partyMembers + -- Build minion skills for _, activeSkill in ipairs(env.player.activeSkillList) do - activeSkill.skillModList = new("ModList", activeSkill.baseSkillModList) + if activeSkill.skillModList and activeSkill.skillModList ~= activeSkill.baseSkillModList then + activeSkill.skillModList = resetReusableModList(activeSkill.skillModList, activeSkill.baseSkillModList) + else + activeSkill.skillModList = new("ModList", activeSkill.baseSkillModList) + end if activeSkill.minion then - activeSkill.minion.modDB = new("ModDB") - activeSkill.minion.modDB.actor = activeSkill.minion + if activeSkill.minion.modDB then + resetReusableModDB(activeSkill.minion.modDB, false, activeSkill.minion) + else + activeSkill.minion.modDB = new("ModDB") + activeSkill.minion.modDB.actor = activeSkill.minion + end calcs.createMinionSkills(env, activeSkill) activeSkill.skillPartName = activeSkill.minion.mainSkill.activeEffect.grantedEffect.name end @@ -1100,9 +1151,7 @@ function calcs.perform(env, skipEHP) env.enemy.output = { } local output = env.player.output - env.partyMembers = env.build.partyTab.actor - env.player.partyMembers = env.partyMembers - local partyTabEnableExportBuffs = env.build.partyTab.enableExportBuffs and env.mode ~= "CALCULATOR" + local partyTabEnableExportBuffs = prepassState and prepassState.partyTabEnableExportBuffs or (env.build.partyTab.enableExportBuffs and env.mode ~= "CALCULATOR") env.minion = env.player.mainSkill.minion if env.minion then @@ -1726,7 +1775,7 @@ function calcs.perform(env, skipEHP) for key, buffModList in pairs(tinctureBuffs) do for _, buff in ipairs(buffModList) do if band(buff.flags, ModFlag.WeaponMelee) == ModFlag.WeaponMelee then - newMod = copyTable(buff, true) + newMod = type(buff.value) == "table" and copyTableSafe(buff, false) or copyTable(buff, true) newMod.flags = bor(band(newMod.flags, bnot(ModFlag.WeaponMelee)), ModFlag.WeaponRanged) modDB:AddList({newMod}) end @@ -1753,8 +1802,7 @@ function calcs.perform(env, skipEHP) for _, value in ipairs(env.modDB:Tabulate(nil, nil, "EnemyModifier")) do local mod = value.value and value.value.mod if mod then - local copy = copyTable(mod, true) - env.minion.modDB:AddMod(modLib.setSource(copy, mod.source or value.mod.source)) + env.minion.modDB:AddMod(modLib.withSource(mod, mod.source or value.mod.source)) end end end @@ -2025,23 +2073,20 @@ function calcs.perform(env, skipEHP) buffs["Spectre"] = buffs["Spectre"] or new("ModList") minionBuffs["Spectre"] = minionBuffs["Spectre"] or new("ModList") for _, modValue in pairs(modData.value) do - local copyModValue = copyTable(modValue) - copyModValue.source = "Spectre:"..spectreData.name + local copyModValue = modLib.withSource(modValue, "Spectre:"..spectreData.name) t_insert(minionBuffs["Spectre"], copyModValue) t_insert(buffs["Spectre"], copyModValue) end elseif modData.name == "MinionModifier" and modData.type == "LIST" then minionBuffs["Spectre"] = minionBuffs["Spectre"] or new("ModList") for _, modValue in pairs(modData.value) do - local copyModValue = copyTable(modValue) - copyModValue.source = "Spectre:"..spectreData.name + local copyModValue = modLib.withSource(modValue, "Spectre:"..spectreData.name) t_insert(minionBuffs["Spectre"], copyModValue) end elseif modData.name == "PlayerModifier" and modData.type == "LIST" then buffs["Spectre"] = buffs["Spectre"] or new("ModList") for _, modValue in pairs(modData.value) do - local copyModValue = copyTable(modValue) - copyModValue.source = "Spectre:"..spectreData.name + local copyModValue = modLib.withSource(modValue, "Spectre:"..spectreData.name) t_insert(buffs["Spectre"], copyModValue) end end @@ -2230,7 +2275,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end if not activeSkill.skillData.auraCannotAffectSelf then @@ -2305,7 +2350,7 @@ function calcs.perform(env, skipEHP) for _, modList in ipairs(lists) do for _, mod in ipairs(modList) do if mod.name == "EnergyShield" or mod.name == "Armour" or mod.name == "Evasion" or mod.name:match("Resist?M?a?x?$") then - local totemMod = copyTable(mod) + local totemMod = type(mod.value) == "table" and copyTableSafe(mod, false) or copyTable(mod) totemMod.name = "Totem"..totemMod.name if scale ~= 1 then if type(totemMod.value) == "number" then @@ -2342,7 +2387,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end local inc = skillModList:Sum("INC", skillCfg, "AuraEffect", "BuffEffect", "DebuffEffect") @@ -2388,7 +2433,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end mult = 0 @@ -2496,7 +2541,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraLinkModList, copyTable(value.mod, true)) + t_insert(extraLinkModList, copyTableSafe(value.mod, false)) -- special handling to add this early if value.mod.name == "ParentNonUniqueFlasksAppliedToYou" then nonUniqueFlasksApplyToMinion = true @@ -2557,7 +2602,7 @@ function calcs.perform(env, skipEHP) source = source..castingMinion.minionData.name end for i = 1, #modList do - modList[i].source = source + modList[i] = modLib.withSource(modList[i], source) end end end @@ -2620,7 +2665,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end if not (activeSkill.minion.modDB:Flag(nil, "SelfAurasCannotAffectAllies") or activeSkill.minion.modDB:Flag(nil, "SelfAurasOnlyAffectYou") or activeSkill.minion.modDB:Flag(nil, "SelfAuraSkillsCannotAffectAllies") or skillModList:Flag(skillCfg, "SelfAurasAffectYouAndLinkedTarget")) then @@ -2683,7 +2728,7 @@ function calcs.perform(env, skipEHP) for _, modList in ipairs(lists) do for _, mod in ipairs(modList) do if mod.name == "EnergyShield" or mod.name == "Armour" or mod.name == "Evasion" or mod.name:match("Resist?M?a?x?$") then - local totemMod = copyTable(mod) + local totemMod = type(mod.value) == "table" and copyTableSafe(mod, false) or copyTable(mod) totemMod.name = "Totem"..totemMod.name if scale ~= 1 then if type(totemMod.value) == "number" then @@ -3185,7 +3230,7 @@ function calcs.perform(env, skipEHP) buffExports["Aura"]["extraAura"].modList:AddMod(value.mod) local totemModBlacklist = value.mod.name and (value.mod.name == "Speed" or value.mod.name == "CritMultiplier" or value.mod.name == "CritChance") if env.player.mainSkill.skillFlags.totem and not totemModBlacklist then - local totemMod = copyTable(value.mod) + local totemMod = type(value.mod.value) == "table" and copyTableSafe(value.mod, false) or copyTable(value.mod) local totemModName, matches = totemMod.name:gsub("Condition:", "Condition:Totem") if matches < 1 then totemModName = "Totem" .. totemMod.name @@ -3481,7 +3526,7 @@ function calcs.perform(env, skipEHP) -- preStack Mine auras for auraName, aura in pairs(buffExports["Aura"]) do if auraName:match("Mine") and not auraName:match(" Limit") then - buffExports["Aura"][auraName] = copyTable(buffExports["Aura"][auraName]) + buffExports["Aura"][auraName] = copyTableSafe(buffExports["Aura"][auraName], false) aura = buffExports["Aura"][auraName] local stackCount = buffExports["EnemyMods"]["Multiplier:"..auraName.."Stack"] and buffExports["EnemyMods"]["Multiplier:"..auraName.."Stack"].value or 0 buffExports["EnemyMods"]["Multiplier:"..auraName.."Stack"] = nil @@ -3499,7 +3544,7 @@ function calcs.perform(env, skipEHP) end end if buffExports["Aura"][auraName.." Limit"] then - buffExports["Aura"][auraName.." Limit"] = copyTable(buffExports["Aura"][auraName.." Limit"]) + buffExports["Aura"][auraName.." Limit"] = copyTableSafe(buffExports["Aura"][auraName.." Limit"], false) aura = buffExports["Aura"][auraName.." Limit"] if stackCount == 0 then buffExports["Aura"][auraName.." Limit"] = nil @@ -3596,5 +3641,12 @@ function calcs.perform(env, skipEHP) end end - cacheData(cacheSkillUUID(env.player.mainSkill, env), env) + if env.requestedSkillUUID or not env.deferCacheWrites then + cacheData(env.requestedSkillUUID or cacheSkillUUID(env.player.mainSkill, env), env) + end +end + +function calcs.perform(env, skipEHP) + local prepassState = calcs.performActorPrepass(env) + return calcs.performSkillPass(env, skipEHP, prepassState) end diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 43af65c78c..1137caf7f4 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -310,7 +310,7 @@ local function applySocketMods(env, gem, groupCfg, socketNum, modSource) socketCfg.skillGem = gem socketCfg.socketNum = socketNum for _, value in ipairs(env.modDB:List(socketCfg, "SocketProperty")) do - env.player.modDB:AddMod(modLib.setSource(value.value, modSource or groupCfg.slotName or "")) + env.player.modDB:AddMod(modLib.withSource(value.value, modSource or groupCfg.slotName or "")) end end @@ -360,6 +360,8 @@ function calcs.initEnv(build, mode, override, specEnv) local cachedMinionDB = specEnv and specEnv.cachedMinionDB or nil local env = specEnv and specEnv.env or nil local accelerate = specEnv and specEnv.accelerate or { } + local cacheGeneration = specEnv and specEnv.cacheGeneration or nil + local recursionGuards = specEnv and specEnv.recursionGuards or nil -- environment variables local override = override or { } @@ -375,9 +377,14 @@ function calcs.initEnv(build, mode, override, specEnv) env.configPlaceholder = build.configTab.placeholder env.calcsInput = build.calcsTab.input env.mode = mode + env.cacheGeneration = cacheGeneration env.spec = override.spec or build.spec env.override = override env.classId = env.spec.curClassId + env.recursionGuards = recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } modDB = new("ModDB") env.modDB = modDB @@ -436,6 +443,11 @@ function calcs.initEnv(build, mode, override, specEnv) wipeEnv(env, accelerate) modDB = env.modDB enemyDB = env.enemyDB + env.cacheGeneration = cacheGeneration or env.cacheGeneration + env.recursionGuards = recursionGuards or env.recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } end -- Set buff mode @@ -569,6 +581,9 @@ function calcs.initEnv(build, mode, override, specEnv) env.minion.modDB.parent = cachedMinionDB end end + env.cachedPlayerDB = cachedPlayerDB + env.cachedEnemyDB = cachedEnemyDB + env.cachedMinionDB = cachedMinionDB if override.conditions then for _, flag in ipairs(override.conditions) do @@ -1054,9 +1069,7 @@ function calcs.initEnv(build, mode, override, specEnv) end end - local modCopy = copyTable(mod) - modLib.setSource(modCopy, item.modSource) - env.itemModDB:ScaleAddMod(modCopy, scale) + env.itemModDB:ScaleAddMod(modLib.withSource(mod, item.modSource), scale) ::skip_mod:: end @@ -1684,7 +1697,7 @@ function calcs.initEnv(build, mode, override, specEnv) } local groupCfg = groupCfgList[slotName or "noSlot"][group] for _, value in ipairs(env.modDB:List(groupCfg, "GroupProperty")) do - env.player.modDB:AddMod(modLib.setSource(value.value, groupCfg.slotName or "")) + env.player.modDB:AddMod(modLib.withSource(value.value, groupCfg.slotName or "")) end if index == env.mainSocketGroup and #socketGroupSkillList > 0 then diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index 5340fcc664..898de1aecc 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -31,14 +31,14 @@ end local function slotMatch(env, skill) local fromItem = (env.player.mainSkill.activeEffect.grantedEffect.fromItem or skill.activeEffect.grantedEffect.fromItem) - fromItem = fromItem or (env.player.mainSkill.activeEffect.srcInstance and env.player.mainSkill.activeEffect.srcInstance.fromItem) or (skill.activeEffect.srcInstance and skill.activeEffect.srcInstance.fromItem) + fromItem = fromItem or calcs.getActiveEffectSourceValue(env.player.mainSkill.activeEffect, "fromItem") or calcs.getActiveEffectSourceValue(skill.activeEffect, "fromItem") local match1 = fromItem and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot - local match2 = (not env.player.mainSkill.activeEffect.grantedEffect.fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup + local match2 = (not fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup return (match1 or match2) end function isTriggered(skill) - return skill.skillData.triggeredByUnique or skill.skillData.triggered or skill.skillTypes[SkillType.InbuiltTrigger] or skill.skillTypes[SkillType.Triggered] or skill.activeEffect.grantedEffect.triggered or (skill.activeEffect.srcInstance and skill.activeEffect.srcInstance.triggered) + return skill.skillData.triggeredByUnique or skill.skillData.triggered or skill.skillTypes[SkillType.InbuiltTrigger] or skill.skillTypes[SkillType.Triggered] or skill.activeEffect.grantedEffect.triggered or calcs.getActiveEffectSourceValue(skill.activeEffect, "triggered") end local function processAddedCastTime(skill, breakdown) @@ -66,7 +66,8 @@ local function packageSkillDataForSimulation(skill, env) end local function defaultComparer(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed + local entry = getGlobalCacheTable(env)[uuid] + local cachedSpeed = entry and (entry.HitSpeed or entry.Speed) return (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) end @@ -74,13 +75,10 @@ end local function findTriggerSkill(env, skill, source, triggerRate, comparer) 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) - end + local entry, uuid = calcs.getCachedSkillEntry(env, skill) - 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 - return skill, GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed, uuid + if entry 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 + return skill, entry.HitSpeed or entry.Speed, uuid end return source, triggerRate, source and cacheSkillUUID(source, env) end @@ -224,9 +222,10 @@ local function CWCHandler(env) local triggerName = "Cast While Channeling" local output = env.player.output local breakdown = env.player.breakdown + local fromItem = calcs.getActiveEffectSourceValue(env.player.mainSkill.activeEffect, "fromItem", env.player.mainSkill.activeEffect.grantedEffect.fromItem) for _, skill in ipairs(env.player.activeSkillList) do - local match1 = env.player.mainSkill.activeEffect.grantedEffect.fromItem and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot - local match2 = (not env.player.mainSkill.activeEffect.grantedEffect.fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup + local match1 = fromItem and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot + local match2 = (not fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup if env.player.mainSkill.triggeredBy.gemData and calcLib.canGrantedEffectSupportActiveSkill(env.player.mainSkill.triggeredBy.gemData.grantedEffect, skill) and skill ~= env.player.mainSkill and (match1 or match2) and not isTriggered(skill) then source, trigRate = findTriggerSkill(env, skill, source, trigRate) end @@ -396,6 +395,7 @@ local function defaultTriggerHandler(env, config) local triggeredSkills = config.triggeredSkills or {} local trigRate = config.trigRate local uuid + local cache = getGlobalCacheTable(env) -- Find trigger skill and triggered skills if config.triggeredSkillCond or config.triggerSkillCond then @@ -446,8 +446,8 @@ local function defaultTriggerHandler(env, config) actor.mainSkill.skillData.ignoresTickRate = actor.mainSkill.skillData.ignoresTickRate or (actor.mainSkill.skillData.storedUses and actor.mainSkill.skillData.storedUses > 1) --Account for source unleash - if source and GlobalCache.cachedData[env.mode][uuid] and source.skillModList:Flag(nil, "HasSeals") and source.skillTypes[SkillType.CanRapidFire] then - local unleashDpsMult = GlobalCache.cachedData[env.mode][uuid].ActiveSkill.skillData.dpsMultiplier or 1 + if source and cache[uuid] and source.skillModList:Flag(nil, "HasSeals") and source.skillTypes[SkillType.CanRapidFire] then + local unleashDpsMult = cache[uuid].MainSkillData.dpsMultiplier or 1 trigRate = trigRate * unleashDpsMult actor.mainSkill.skillFlags.HasSeals = true actor.mainSkill.skillData.ignoresTickRate = true @@ -457,8 +457,8 @@ local function defaultTriggerHandler(env, config) end --Account for skills that can hit multiple times per use - if source and GlobalCache.cachedData[env.mode][uuid] and source.skillPartName and source.skillPartName:match("(.*)All(.*)Projectiles(.*)") and source.skillFlags.projectile then - local multiHitDpsMult = GlobalCache.cachedData[env.mode][uuid].Env.player.output.ProjectileCount or 1 + if source and cache[uuid] and source.skillPartName and source.skillPartName:match("(.*)All(.*)Projectiles(.*)") and source.skillFlags.projectile then + local multiHitDpsMult = cache[uuid].Output.ProjectileCount or 1 trigRate = trigRate * multiHitDpsMult if breakdown then t_insert(breakdown.EffectiveSourceRate, s_format("x %.2f ^8(%d projectiles hit)", multiHitDpsMult, multiHitDpsMult)) @@ -476,11 +476,11 @@ local function defaultTriggerHandler(env, config) end -- Battlemage's Cry uptime - if actor.mainSkill.skillData.triggeredByBattleMageCry and GlobalCache.cachedData[env.mode][uuid] and source and source.skillTypes[SkillType.Melee] then - local battleMageExertsCount = GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleCryExertsCount - local battleMageDuration = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleMageCryDuration, data.misc.ServerTickTime) - local battleMageCastTime = GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleMageCryCastTime - local battleMageCooldown = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleMageCryCooldown, data.misc.ServerTickTime) + if actor.mainSkill.skillData.triggeredByBattleMageCry and cache[uuid] and source and source.skillTypes[SkillType.Melee] then + local battleMageExertsCount = cache[uuid].Output.BattleCryExertsCount or 0 + local battleMageDuration = ceil_b(cache[uuid].Output.BattleMageCryDuration or 0, data.misc.ServerTickTime) + local battleMageCastTime = cache[uuid].Output.BattleMageCryCastTime or 0 + local battleMageCooldown = ceil_b(cache[uuid].Output.BattleMageCryCooldown or 0, data.misc.ServerTickTime) -- Cap the number of hits that happen during the duration local battleMageHits = m_max(m_min(trigRate * battleMageDuration, battleMageExertsCount), 0) @@ -495,11 +495,11 @@ local function defaultTriggerHandler(env, config) end -- Infernal Cry uptime - if actor.mainSkill.activeEffect.grantedEffect.name == "Combust" and GlobalCache.cachedData[env.mode][uuid] and source and source.skillTypes[SkillType.Melee] then - local infernalCryExertsCount = GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalExertsCount - local infernalCryDuration = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalCryDuration, data.misc.ServerTickTime) - local infernalCryCastTime = GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalCryCastTime - local infernalCryCooldown = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalCryCooldown, data.misc.ServerTickTime) + if actor.mainSkill.activeEffect.grantedEffect.name == "Combust" and cache[uuid] and source and source.skillTypes[SkillType.Melee] then + local infernalCryExertsCount = cache[uuid].Output.InfernalExertsCount or 0 + local infernalCryDuration = ceil_b(cache[uuid].Output.InfernalCryDuration or 0, data.misc.ServerTickTime) + local infernalCryCastTime = cache[uuid].Output.InfernalCryCastTime or 0 + local infernalCryCooldown = ceil_b(cache[uuid].Output.InfernalCryCooldown or 0, data.misc.ServerTickTime) -- Cap the number of hits that happen during the duration local infernalCryHits = m_max(m_min(trigRate * infernalCryDuration, infernalCryExertsCount), 0) @@ -515,14 +515,11 @@ local function defaultTriggerHandler(env, config) -- Handling for mana spending rate for Manaforged Arrows Support if actor.mainSkill.skillData.triggeredByManaforged and trigRate > 0 then - local triggeredUUID = cacheSkillUUID(actor.mainSkill, env) - if not GlobalCache.cachedData[env.mode][triggeredUUID] then - calcs.buildActiveSkill(env, env.mode, actor.mainSkill, triggeredUUID, {triggeredUUID}) - end - local triggeredManaCost = GlobalCache.cachedData[env.mode][triggeredUUID].Env.player.output.ManaCostRaw or 0 + local triggeredEntry = calcs.getCachedSkillEntry(env, actor.mainSkill, { cacheSkillUUID(actor.mainSkill, env) }, { breakdown = breakdown, section = "EffectiveSourceRate" }) + local triggeredManaCost = triggeredEntry and triggeredEntry.Output.ManaCostRaw or 0 if triggeredManaCost > 0 then local manaSpentThreshold = triggeredManaCost * actor.mainSkill.skillData.ManaForgedArrowsPercentThreshold - local sourceManaCost = GlobalCache.cachedData[env.mode][uuid].Env.player.output.ManaCostRaw or 0 + local sourceManaCost = cache[uuid].Output.ManaCostRaw or 0 if sourceManaCost > 0 then if breakdown then breakdown.EffectiveSourceRate = { @@ -580,9 +577,10 @@ local function defaultTriggerHandler(env, config) output.TriggerRateCap = 1 / actionCooldownTickRounded end if config.triggerName == "Doom Blast" and env.build.configTab.input["doomBlastSource"] == "expiration" then - local expirationRate = 1 / GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration + local sourceDuration = cache[uuid].Output.Duration or 0 + local expirationRate = sourceDuration > 0 and 1 / sourceDuration or 0 if breakdown and breakdown.EffectiveSourceRate then - breakdown.EffectiveSourceRate[1] = s_format("1 / %.2f ^8(source curse duration)", GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration) + breakdown.EffectiveSourceRate[1] = s_format("1 / %.2f ^8(source curse duration)", sourceDuration) end if expirationRate > trigRate then env.player.modDB:NewMod("UsesCurseOverlaps", "FLAG", true, "Config") @@ -724,14 +722,14 @@ local function defaultTriggerHandler(env, config) local triggerChanceBreakdown = {} --Accuracy and crit chance - if source and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and GlobalCache.cachedData[env.mode][uuid] and not config.triggerOnUse then + if source and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and cache[uuid] and not config.triggerOnUse then - local sourceHitChance = GlobalCache.cachedData[env.mode][uuid].HitChance or 0 + local sourceHitChance = cache[uuid].HitChance or 0 if sourceHitChance ~= 100 then -- Some skills hit with both weapons at the same time. Each weapon rolls accuracy and crit independently if source and env.player.weaponData1.type and env.player.weaponData2.type and source.skillData.doubleHitsWhenDualWielding then - local mainHandHit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.MainHand.HitChance - local offHandHit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.OffHand.HitChance + local mainHandHit = cache[uuid].Output.MainHand.HitChance or 0 + local offHandHit = cache[uuid].Output.OffHand.HitChance or 0 local bothHit = mainHandHit * offHandHit / 100 local mainHandMiss = (100 - mainHandHit) local offHandMiss = (100 - offHandHit) @@ -748,15 +746,15 @@ local function defaultTriggerHandler(env, config) end end if actor.mainSkill.skillData.triggerOnCrit then - local onCritChance = actor.mainSkill.skillData.chanceToTriggerOnCrit or (GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].Env.player.mainSkill.skillData.chanceToTriggerOnCrit) + local onCritChance = actor.mainSkill.skillData.chanceToTriggerOnCrit or (cache[uuid] and cache[uuid].MainSkillData.chanceToTriggerOnCrit) config.triggerChance = config.triggerChance or actor.mainSkill.skillData.chanceToTriggerOnCrit or onCritChance - local sourceCritChance = GlobalCache.cachedData[env.mode][uuid].CritChance or 0 + local sourceCritChance = cache[uuid].CritChance or 0 if sourceCritChance ~= 100 then -- Some skills hit with both weapons at the same time. Each weapon rolls accuracy and crit independently if source and env.player.weaponData1.type and env.player.weaponData2.type and source.skillData.doubleHitsWhenDualWielding then - local mainHandCrit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.MainHand.CritChance - local offHandCrit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.OffHand.CritChance + local mainHandCrit = cache[uuid].Output.MainHand.CritChance or 0 + local offHandCrit = cache[uuid].Output.OffHand.CritChance or 0 local bothHit = mainHandCrit * offHandCrit / 100 local mainHandMiss = (100 - mainHandCrit) local offHandMiss = (100 - offHandCrit) @@ -785,7 +783,8 @@ local function defaultTriggerHandler(env, config) -- If the current triggered skill ignores tick rate and is the only triggered skill by this trigger use charge based calcs if actor.mainSkill.skillData.ignoresTickRate and ( not config.triggeredSkillCond or (triggeredSkills and #triggeredSkills == 1 and triggeredSkills[1] == packageSkillDataForSimulation(actor.mainSkill, env)) ) then - local overlaps = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or config.overlaps + local stageCount = calcs.getActiveEffectSelection(env.player.mainSkill.activeEffect, "skillStageCountCalcs", "skillStageCount", false, env.player.mainSkill.activeStageCount and env.player.mainSkill.activeStageCount + 1) + local overlaps = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and stageCount or config.overlaps output.SkillTriggerRate = m_min(output.TriggerRateCap, output.EffectiveSourceRate * (overlaps or 1)) if breakdown then if overlaps then @@ -808,7 +807,8 @@ local function defaultTriggerHandler(env, config) end -- stagesAreOverlaps is the skill part which makes the stages behave as overlaps - local hits_per_cast = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or 1 + local stageCount = calcs.getActiveEffectSelection(env.player.mainSkill.activeEffect, "skillStageCountCalcs", "skillStageCount", false, env.player.mainSkill.activeStageCount and env.player.mainSkill.activeStageCount + 1) + local hits_per_cast = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and stageCount or 1 output.SkillTriggerRate = hits_per_cast * output.SkillTriggerRate if breakdown then breakdown.SkillTriggerRate = { @@ -1074,8 +1074,9 @@ local configTable = { 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 + local entry = getGlobalCacheTable(env)[uuid] + local cachedSpeed = entry and (entry.HitSpeed or entry.Speed) + local cachedManaCost = entry and entry.ManaCost return ( (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) ) and ( (cachedManaCost or 0) >= requiredManaCost ) end, triggerSkillCond = function(env, skill) @@ -1088,8 +1089,9 @@ local configTable = { 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 + local entry = getGlobalCacheTable(env)[uuid] + local cachedSpeed = entry and (entry.HitSpeed or entry.Speed) + local cachedLifeCost = entry and entry.LifeCost return ( (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) ) and ( (cachedLifeCost or 0) >= requiredLifeCost ) end, triggerSkillCond = function(env, skill) @@ -1279,15 +1281,13 @@ local configTable = { end, ["shattershard"] = function(env) env.player.mainSkill.skillFlags.globalTrigger = true - local uuid = cacheSkillUUID(env.player.mainSkill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, env.player.mainSkill, uuid, {uuid}) - end - env.player.mainSkill.skillData.triggerRateCapOverride = 1 / GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration + local entry = calcs.getCachedSkillEntry(env, env.player.mainSkill, { cacheSkillUUID(env.player.mainSkill, env) }, { breakdown = env.player.breakdown, section = "SkillTriggerRate" }) + local duration = entry and entry.Output.Duration or 0 + env.player.mainSkill.skillData.triggerRateCapOverride = duration > 0 and 1 / duration or 0 if env.player.breakdown then env.player.breakdown.SkillTriggerRate = { s_format("Shattershard uses duration as pseudo cooldown"), - s_format("1 / %.2f ^8(Shattershard duration)", GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration), + s_format("1 / %.2f ^8(Shattershard duration)", duration), s_format("= %.2f ^8per second", env.player.mainSkill.skillData.triggerRateCapOverride), } end @@ -1298,7 +1298,7 @@ local configTable = { return {triggerSkillCond = function(env, skill) return skill.skillTypes[SkillType.Melee] end, comparer = function(env, uuid, source, triggerRate) -- Skills with no uptime ratio are not exerted by battlemage so should not be considered. - local uptimeRatio = GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattlemageUpTimeRatio + local uptimeRatio = getGlobalCacheTable(env)[uuid].Output.BattlemageUpTimeRatio return defaultComparer(env, uuid, source, triggerRate) and uptimeRatio end, triggeredSkillCond = function(env, skill) return skill.skillData.triggeredByBattleMageCry and slotMatch(env, skill) end} @@ -1333,7 +1333,7 @@ local configTable = { return {triggerSkillCond = function(env, skill) return skill.skillTypes[SkillType.Melee] end, comparer = function(env, uuid, source, triggerRate) -- Skills with no uptime ratio are not exerted by infernal cry so should not be considered. - local uptimeRatio = GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalUpTimeRatio + local uptimeRatio = getGlobalCacheTable(env)[uuid].Output.InfernalUpTimeRatio return defaultComparer(env, uuid, source, triggerRate) and uptimeRatio end,} end, @@ -1432,14 +1432,11 @@ local configTable = { for _, skill in ipairs(env.player.activeSkillList) do if skill.activeEffect.grantedEffect.name == "Snipe" and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot then skill.skillData.hitTimeMultiplier = snipeStages - 0.5 - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].Env.player.output.HitSpeed + local entry = calcs.getCachedSkillEntry(env, skill) + local cachedSpeed = entry and entry.Output.HitSpeed if (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] and ((not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (trigRate or 0))) then trigRate = cachedSpeed - env.player.output.ChannelTimeToTrigger = GlobalCache.cachedData[env.mode][uuid].Env.player.output.HitTime + env.player.output.ChannelTimeToTrigger = entry.Output.HitTime source = skill end end @@ -1457,7 +1454,7 @@ local configTable = { ["avenging flame"] = function(env) return {triggerSkillCond = function(env, skill) return skill.skillFlags.totem and slotMatch(env, skill) end, comparer = function(env, uuid, source, currentTotemLife) - local totemLife = GlobalCache.cachedData[env.mode][uuid].Env.player.output.TotemLife + local totemLife = getGlobalCacheTable(env)[uuid].Output.TotemLife return (not source and totemLife) or (totemLife and totemLife > (currentTotemLife or 0)) end, ignoreSourceRate = true} @@ -1549,6 +1546,27 @@ end function calcs.triggers(env, actor) if actor and not actor.mainSkill.skillFlags.disable and not (env.limitedSkills and env.limitedSkills[cacheSkillUUID(actor.mainSkill, env)]) then + local recursionGuards = env.recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } + env.recursionGuards = recursionGuards + local actorId = actor == env.minion and "minion" or "player" + local guardKey = env.mode..":"..actorId..":"..cacheSkillUUID(actor.mainSkill, env) + if recursionGuards.triggerResolution[guardKey] then + actor.mainSkill.skillData.triggered = nil + actor.mainSkill.infoMessage = "Trigger cycle detected" + actor.output.TriggerRateCap = 0 + actor.output.EffectiveSourceRate = 0 + actor.output.SkillTriggerRate = 0 + if actor.breakdown then + actor.breakdown.SkillTriggerRate = actor.breakdown.SkillTriggerRate or { } + t_insert(actor.breakdown.SkillTriggerRate, "Trigger cycle detected; assuming 0 trigger rate") + end + return + end + recursionGuards.triggerResolution[guardKey] = true + local skillName = actor.mainSkill.activeEffect.grantedEffect.name local triggerName = actor.mainSkill.triggeredBy and actor.mainSkill.triggeredBy.grantedEffect.name local uniqueName = isTriggered(actor.mainSkill) and getUniqueItemTriggerName(actor.mainSkill) @@ -1563,11 +1581,12 @@ function calcs.triggers(env, actor) if config then config.actor = config.actor or actor config.triggerName = config.triggerName or triggerName or skillName or uniqueName - config.triggerChance = config.triggerChance or (actor.mainSkill.activeEffect.srcInstance and actor.mainSkill.activeEffect.srcInstance.triggerChance) + config.triggerChance = config.triggerChance or calcs.getActiveEffectSourceValue(actor.mainSkill.activeEffect, "triggerChance") local triggerHandler = config.customHandler or defaultTriggerHandler triggerHandler(env, config) else actor.mainSkill.skillData.triggered = nil end + recursionGuards.triggerResolution[guardKey] = nil end end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index daaf130572..2e0ea751bf 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -69,14 +69,44 @@ local function infoDump(env) prettyPrintTable(env.player.output) end +local function appendBreakdownNote(breakdown, section, note) + if not breakdown or not section or not note then + return + end + if not breakdown[section] then + breakdown[section] = { } + end + for _, line in ipairs(breakdown[section]) do + if line == note then + return + end + end + t_insert(breakdown[section], note) +end + +function calcs.getCachedSkillEntry(env, skill, limitedProcessingFlags, noteTarget) + local uuid = cacheSkillUUID(skill, env) + local cache = getGlobalCacheTable(env) + local entry = cache[uuid] + if not entry then + calcs.buildActiveSkill(env, env.mode, skill, uuid, limitedProcessingFlags) + entry = cache[uuid] + end + if entry and entry.Incomplete and noteTarget then + appendBreakdownNote(noteTarget.breakdown, noteTarget.section, entry.DebugNote) + end + return entry, uuid +end + -- Generate a function for calculating the effect of some modification to the environment local function getCalculator(build, fullInit, modFunc) -- Initialise environment - local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR") + local cacheGeneration = nextCalculatorCacheGeneration(build) + local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", nil, { cacheGeneration = cacheGeneration }) -- Run base calculation pass calcs.perform(env) - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil }) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = cacheGeneration }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -101,8 +131,10 @@ local function getCalculator(build, fullInit, modFunc) modFunc(env, ...) -- Run calculation pass + cacheGeneration = nextCalculatorCacheGeneration(build) + env.cacheGeneration = cacheGeneration calcs.perform(env) - fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = cacheGeneration }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -122,9 +154,10 @@ end -- Get calculator for other changes (adding/removing nodes, items, gems, etc) function calcs.getMiscCalculator(build) -- Run base calculation pass - local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR") + local cacheGeneration = nextCalculatorCacheGeneration(build) + local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", nil, { cacheGeneration = cacheGeneration }) calcs.perform(env) - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = cacheGeneration }) local usedFullDPS = #fullDPS.skills > 0 if usedFullDPS then env.player.output.SkillDPS = fullDPS.skills @@ -132,13 +165,11 @@ function calcs.getMiscCalculator(build) env.player.output.FullDotDPS = fullDPS.TotalDotDPS end return function(override, useFullDPS) - local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override) + local compareGeneration = nextCalculatorCacheGeneration(build) + local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override, { cacheGeneration = compareGeneration }) calcs.perform(env) 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, cacheGeneration = compareGeneration }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -176,6 +207,7 @@ end function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil + fullEnv.deferCacheWrites = true local fullDPS = { combinedDPS = 0, @@ -198,7 +230,6 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local igniteSource = "" local burningGroundSource = "" local causticGroundSource = "" - for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = getActiveSkillCount(activeSkill) @@ -206,125 +237,130 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv + local output = usedEnv.player.output + local minionOutput = usedEnv.minion and usedEnv.minion.output or nil + local mirage = activeSkill.mirage + local infoMessage2 = activeSkill.infoMessage2 local minionName = nil - if activeSkill.minion or usedEnv.minion then - if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then - minionName = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData.name..": ") or "" - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = usedEnv.minion.output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName..activeSkill.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + usedEnv.minion.output.TotalDPS * activeSkillCount - end - if usedEnv.minion.output.BleedDPS and usedEnv.minion.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = usedEnv.minion.output.BleedDPS + + if minionOutput then + if minionOutput.TotalDPS and minionOutput.TotalDPS > 0 then + minionName = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData and usedEnv.minion.minionData.name..": ") or "" + t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = minionOutput.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName..activeSkill.skillPartName }) + fullDPS.combinedDPS = fullDPS.combinedDPS + minionOutput.TotalDPS * activeSkillCount + end + if minionOutput.BleedDPS and minionOutput.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = minionOutput.BleedDPS bleedSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.minion.output.IgniteDPS and usedEnv.minion.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = usedEnv.minion.output.IgniteDPS + if minionOutput.IgniteDPS and minionOutput.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = minionOutput.IgniteDPS igniteSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.minion.output.PoisonDPS and usedEnv.minion.output.PoisonDPS > 0 then - fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + usedEnv.minion.output.TotalPoisonDPS * activeSkillCount + if minionOutput.PoisonDPS and minionOutput.PoisonDPS > 0 then + fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + minionOutput.TotalPoisonDPS * activeSkillCount end - if usedEnv.minion.output.ImpaleDPS and usedEnv.minion.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.minion.output.ImpaleDPS * activeSkillCount + if minionOutput.ImpaleDPS and minionOutput.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + minionOutput.ImpaleDPS * activeSkillCount end - if usedEnv.minion.output.DecayDPS and usedEnv.minion.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.minion.output.DecayDPS + if minionOutput.DecayDPS and minionOutput.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + minionOutput.DecayDPS end - if usedEnv.minion.output.TotalDot and usedEnv.minion.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.minion.output.TotalDot + if minionOutput.TotalDot and minionOutput.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + minionOutput.TotalDot end - if usedEnv.minion.output.CullMultiplier and usedEnv.minion.output.CullMultiplier > 1 and usedEnv.minion.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = usedEnv.minion.output.CullMultiplier + if minionOutput.CullMultiplier and minionOutput.CullMultiplier > 1 and minionOutput.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = minionOutput.CullMultiplier end - -- This is a fix to prevent skills such as Absolution or Dominating Blow from being counted multiple times when increasing minions count if (activeSkill.activeEffect.grantedEffect.name:match("Absolution") and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce")) or (activeSkill.activeEffect.grantedEffect.name:match("Dominating Blow") and fullEnv.modDB:Flag(false, "Condition:DominatingBlowSkillDamageCountedOnce")) - or (activeSkill.activeEffect.grantedEffect.name:match("Holy Strike") and fullEnv.modDB:Flag(false, "Condition:HolyStrikeSkillDamageCountedOnce"))then + or (activeSkill.activeEffect.grantedEffect.name:match("Holy Strike") and fullEnv.modDB:Flag(false, "Condition:HolyStrikeSkillDamageCountedOnce")) + or infoMessage2 == "Skill Damage" then activeSkillCount = 1 activeSkill.infoMessage2 = "Skill Damage" end end - if activeSkill.mirage then - local mirageCount = (activeSkill.mirage.count or 1) * activeSkillCount - if activeSkill.mirage.output.TotalDPS and activeSkill.mirage.output.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = activeSkill.mirage.name .. " (Mirage)", dps = activeSkill.mirage.output.TotalDPS, count = mirageCount, trigger = activeSkill.mirage.infoTrigger, skillPart = activeSkill.mirage.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + activeSkill.mirage.output.TotalDPS * mirageCount + if mirage and mirage.output then + local mirageOutput = mirage.output + local mirageCount = (mirage.count or 1) * activeSkillCount + if mirageOutput.TotalDPS and mirageOutput.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = mirage.name .. " (Mirage)", dps = mirageOutput.TotalDPS, count = mirageCount, trigger = mirage.infoTrigger, skillPart = mirage.skillPartName }) + fullDPS.combinedDPS = fullDPS.combinedDPS + mirageOutput.TotalDPS * mirageCount end - if activeSkill.mirage.output.BleedDPS and activeSkill.mirage.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = activeSkill.mirage.output.BleedDPS + if mirageOutput.BleedDPS and mirageOutput.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = mirageOutput.BleedDPS bleedSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" end - if activeSkill.mirage.output.IgniteDPS and activeSkill.mirage.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = activeSkill.mirage.output.IgniteDPS + if mirageOutput.IgniteDPS and mirageOutput.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = mirageOutput.IgniteDPS igniteSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" end - if activeSkill.mirage.output.PoisonDPS and activeSkill.mirage.output.PoisonDPS > 0 then - fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + activeSkill.mirage.output.TotalPoisonDPS * mirageCount + if mirageOutput.PoisonDPS and mirageOutput.PoisonDPS > 0 then + fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + mirageOutput.TotalPoisonDPS * mirageCount end - if activeSkill.mirage.output.ImpaleDPS and activeSkill.mirage.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + activeSkill.mirage.output.ImpaleDPS * mirageCount + if mirageOutput.ImpaleDPS and mirageOutput.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + mirageOutput.ImpaleDPS * mirageCount end - if activeSkill.mirage.output.DecayDPS and activeSkill.mirage.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + activeSkill.mirage.output.DecayDPS + if mirageOutput.DecayDPS and mirageOutput.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + mirageOutput.DecayDPS end - if activeSkill.mirage.output.TotalDot and activeSkill.mirage.output.TotalDot > 0 and (activeSkill.skillFlags.DotCanStack or (usedEnv.player.output.TotalDot and usedEnv.player.output.TotalDot == 0)) then - fullDPS.dotDPS = fullDPS.dotDPS + activeSkill.mirage.output.TotalDot * (activeSkill.skillFlags.DotCanStack and mirageCount or 1) + if mirageOutput.TotalDot and mirageOutput.TotalDot > 0 and (activeSkill.skillFlags.DotCanStack or (output and output.TotalDot and output.TotalDot == 0)) then + fullDPS.dotDPS = fullDPS.dotDPS + mirageOutput.TotalDot * (activeSkill.skillFlags.DotCanStack and mirageCount or 1) end - if activeSkill.mirage.output.CullMultiplier and activeSkill.mirage.output.CullMultiplier > 1 and activeSkill.mirage.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = activeSkill.mirage.output.CullMultiplier + if mirageOutput.CullMultiplier and mirageOutput.CullMultiplier > 1 and mirageOutput.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = mirageOutput.CullMultiplier end - if activeSkill.mirage.output.BurningGroundDPS and activeSkill.mirage.output.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = activeSkill.mirage.output.BurningGroundDPS + if mirageOutput.BurningGroundDPS and mirageOutput.BurningGroundDPS > fullDPS.burningGroundDPS then + fullDPS.burningGroundDPS = mirageOutput.BurningGroundDPS burningGroundSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" end - if activeSkill.mirage.output.CausticGroundDPS and activeSkill.mirage.output.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = activeSkill.mirage.output.CausticGroundDPS + if mirageOutput.CausticGroundDPS and mirageOutput.CausticGroundDPS > fullDPS.causticGroundDPS then + fullDPS.causticGroundDPS = mirageOutput.CausticGroundDPS causticGroundSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" end end - if usedEnv.player.output.TotalDPS and usedEnv.player.output.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = usedEnv.player.output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName and activeSkill.infoMessage2 or activeSkill.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + usedEnv.player.output.TotalDPS * activeSkillCount + if output.TotalDPS and output.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName and infoMessage2 or activeSkill.skillPartName }) + fullDPS.combinedDPS = fullDPS.combinedDPS + output.TotalDPS * activeSkillCount end - if usedEnv.player.output.BleedDPS and usedEnv.player.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = usedEnv.player.output.BleedDPS + if output.BleedDPS and output.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = output.BleedDPS bleedSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.player.output.CorruptingBloodDPS and usedEnv.player.output.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then - fullDPS.corruptingBloodDPS = usedEnv.player.output.CorruptingBloodDPS + if output.CorruptingBloodDPS and output.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then + fullDPS.corruptingBloodDPS = output.CorruptingBloodDPS corruptingBloodSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.player.output.IgniteDPS and usedEnv.player.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = usedEnv.player.output.IgniteDPS + if output.IgniteDPS and output.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = output.IgniteDPS igniteSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.player.output.BurningGroundDPS and usedEnv.player.output.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = usedEnv.player.output.BurningGroundDPS + if output.BurningGroundDPS and output.BurningGroundDPS > fullDPS.burningGroundDPS then + fullDPS.burningGroundDPS = output.BurningGroundDPS burningGroundSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.player.output.PoisonDPS and usedEnv.player.output.PoisonDPS > 0 then - fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + usedEnv.player.output.TotalPoisonDPS * activeSkillCount + if output.PoisonDPS and output.PoisonDPS > 0 then + fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + output.TotalPoisonDPS * activeSkillCount end - if usedEnv.player.output.CausticGroundDPS and usedEnv.player.output.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = usedEnv.player.output.CausticGroundDPS + if output.CausticGroundDPS and output.CausticGroundDPS > fullDPS.causticGroundDPS then + fullDPS.causticGroundDPS = output.CausticGroundDPS causticGroundSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.player.output.ImpaleDPS and usedEnv.player.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.player.output.ImpaleDPS * activeSkillCount + if output.ImpaleDPS and output.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + output.ImpaleDPS * activeSkillCount end - if usedEnv.player.output.DecayDPS and usedEnv.player.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.player.output.DecayDPS + if output.DecayDPS and output.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + output.DecayDPS end - if usedEnv.player.output.TotalDot and usedEnv.player.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.player.output.TotalDot * (activeSkill.skillFlags.DotCanStack and activeSkillCount or 1) + if output.TotalDot and output.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + output.TotalDot * (activeSkill.skillFlags.DotCanStack and activeSkillCount or 1) end - if usedEnv.player.output.CullMultiplier and usedEnv.player.output.CullMultiplier > 1 and usedEnv.player.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = usedEnv.player.output.CullMultiplier + if output.CullMultiplier and output.CullMultiplier > 1 and output.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = output.CullMultiplier end - -- Re-Build env calculator for new run local accelerationTbl = { nodeAlloc = true, requirementsItems = true, @@ -332,12 +368,19 @@ function calcs.calcFullDPS(build, mode, override, specEnv) skills = true, everything = true, } - fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl }) + fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { + cachedPlayerDB = cachedPlayerDB, + cachedEnemyDB = cachedEnemyDB, + cachedMinionDB = cachedMinionDB, + env = fullEnv, + accelerate = accelerationTbl, + cacheGeneration = fullEnv.cacheGeneration, + recursionGuards = fullEnv.recursionGuards, + }) end end end - -- Re-Add ailment DPS components fullDPS.TotalDotDPS = 0 if fullDPS.bleedDPS > 0 then t_insert(fullDPS.skills, { name = "Best Bleed DPS", dps = fullDPS.bleedDPS, count = 1, source = bleedSource }) @@ -389,28 +432,52 @@ end -- Process active skill function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingFlags) - local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) + targetUUID = targetUUID or cacheSkillUUID(skill, env) + local guardKey = mode..":"..targetUUID + local recursionGuards = env.recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } + env.recursionGuards = recursionGuards + if recursionGuards.buildingSkills[guardKey] then + local cache = getGlobalCacheTable(env) + cache[targetUUID] = cache[targetUUID] or buildSafeSkillCacheEntry(targetUUID, skill, s_format("Recursive skill cache resolution detected for %s; assuming 0 until the current calculation completes", skill.activeEffect.grantedEffect.name)) + return false + end + recursionGuards.buildingSkills[guardKey] = true + + local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override, { + cachedPlayerDB = env.cachedPlayerDB, + cachedEnemyDB = env.cachedEnemyDB, + cachedMinionDB = env.cachedMinionDB, + cacheGeneration = env.cacheGeneration, + recursionGuards = recursionGuards, + }) -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops fullEnv.limitedSkills = fullEnv.limitedSkills or {} - for _, uuid in ipairs(env.limitedSkills or {}) do - fullEnv.limitedSkills[uuid] = true + for key, value in pairs(env.limitedSkills or {}) do + fullEnv.limitedSkills[type(key) == "number" and value or key] = true end for _, uuid in ipairs(limitedProcessingFlags or {}) do fullEnv.limitedSkills[uuid] = true end - targetUUID = targetUUID or cacheSkillUUID(skill, env) for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do local activeSkillUUID = cacheSkillUUID(activeSkill, fullEnv) if activeSkillUUID == targetUUID then fullEnv.player.mainSkill = activeSkill + fullEnv.requestedSkillUUID = targetUUID calcs.perform(fullEnv, true) - return + fullEnv.requestedSkillUUID = nil + recursionGuards.buildingSkills[guardKey] = nil + return true end end + recursionGuards.buildingSkills[guardKey] = nil ConPrintf("[calcs.buildActiveSkill] Failed to process skill: " .. skill.activeEffect.grantedEffect.name) + return false end -- Build output for display in the side bar or calcs tab @@ -422,7 +489,7 @@ function calcs.buildOutput(build, mode) local output = env.player.output -- Build output across all skills added to FullDPS skills - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil }) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = nextCalculatorCacheGeneration(build) }) -- Add Full DPS data to main `env` env.player.output.SkillDPS = fullDPS.skills @@ -431,19 +498,16 @@ function calcs.buildOutput(build, mode) if mode == "MAIN" then for _, skill in ipairs(env.player.activeSkillList) do - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[mode][uuid] then - calcs.buildActiveSkill(env, mode, skill, uuid) - end - if GlobalCache.cachedData[mode][uuid] then + local entry = calcs.getCachedSkillEntry(env, skill) + if entry then output.EnergyShieldProtectsMana = env.modDB:Flag(nil, "EnergyShieldProtectsMana") for pool, costResource in pairs({["LifeUnreserved"] = "LifeCost", ["ManaUnreserved"] = "ManaCost", ["Rage"] = "RageCost", ["EnergyShield"] = "ESCost"}) do - local cachedCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource] + local cachedCost = entry.Output[costResource] if cachedCost then local totalPool = (output.EnergyShieldProtectsMana and costResource == "ManaCost" and output["EnergyShield"] or 0) + (output[pool] or 0) if totalPool < cachedCost then local rawPool = pool:gsub("Unreserved$", "") - local reservation = GlobalCache.cachedData[mode][uuid].Env.player.mainSkill and GlobalCache.cachedData[mode][uuid].Env.player.mainSkill.skillData[rawPool .. "ReservedPercent"] + local reservation = entry.MainSkillData[rawPool .. "ReservedPercent"] -- Skill has both cost and reservation check if there's available pool for raw cost before reservation if not reservation or (reservation and (totalPool + m_ceil((output[rawPool] or 0) * reservation / 100)) < cachedCost) then if env.player.mainSkill and env.player.mainSkill.activeEffect.grantedEffect.name == skill.activeEffect.grantedEffect.name then @@ -456,7 +520,7 @@ function calcs.buildOutput(build, mode) end end for pool, costResource in pairs({["LifeUnreservedPercent"] = "LifePercentCost", ["ManaUnreservedPercent"] = "ManaPercentCost"}) do - local cachedCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource] + local cachedCost = entry.Output[costResource] if cachedCost then if (output[pool] or 0) < cachedCost then output[costResource.."PercentCostWarningList"] = output[costResource.."PercentCostWarningList"] or {} diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index ac32512910..58b9bd5bbc 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -20,6 +20,7 @@ local b_and = bit.band local b_xor = bit.bxor common = { } +local graphNodeTagKey = "__pobGraphNodeTag" -- External libraries common.curl = require("lcurl.safe") @@ -404,20 +405,39 @@ function writeLuaTable(out, t, indent) out:write('}') end --- Make a copy of a table and all subtables -function copyTable(tbl, noRecurse) +function graphNodeTag(obj, label) + if launch.devMode and type(obj) == "table" then + rawset(obj, graphNodeTagKey, rawget(obj, graphNodeTagKey) or label or true) + end + return obj +end + +local function copyTableInternal(tbl, noRecurse) local out = {} for k, v in pairs(tbl) do if not noRecurse and type(v) == "table" then - out[k] = copyTable(v) + out[k] = copyTableInternal(v) else out[k] = v end end return out end + +-- copyTable() is for plain acyclic data only. Graph objects such as env, activeSkill, +-- ModDB, ModList, and cache entries must use copyTableSafe() or targeted/manual cloning. +function copyTable(tbl, noRecurse) + if launch.devMode then + local graphNodeLabel = rawget(tbl, graphNodeTagKey) + if graphNodeLabel then + error("copyTable() cannot clone graph object '"..tostring(graphNodeLabel).."'; use copyTableSafe() or a targeted clone") + end + end + return copyTableInternal(tbl, noRecurse) +end do local subTableMap = { } + -- copyTableSafe() is reserved for graph-risk call sites such as shared mod payloads. function copyTableSafe(tbl, noRecurse, preserveMeta, isSubTable) local out = {} if not noRecurse then @@ -782,23 +802,123 @@ function cacheSkillUUID(skill, env) return strName.."_"..strSlotName.."_"..tostring(slotIndx) .. "_" .. tostring(groupIdx) end +local calculatorCacheGeneration = 0 + +function nextCalculatorCacheGeneration(build) + calculatorCacheGeneration = calculatorCacheGeneration + 1 + return s_format("%s:%d", tostring(build and build.outputRevision or 0), calculatorCacheGeneration) +end + +function getGlobalCacheTable(modeOrEnv, build, cacheGeneration) + local mode = modeOrEnv + if type(modeOrEnv) == "table" then + local env = modeOrEnv + mode = env.mode + build = env.build + cacheGeneration = env.cacheGeneration + end + if mode ~= "CALCULATOR" then + return GlobalCache.cachedData[mode] + end + local namespace = cacheGeneration or s_format("%s:default", tostring(build and build.outputRevision or 0)) + GlobalCache.cachedData.CALCULATOR[namespace] = GlobalCache.cachedData.CALCULATOR[namespace] or {} + return GlobalCache.cachedData.CALCULATOR[namespace] +end + +local function buildCachedMainSkillData(mainSkill) + local skillData = mainSkill.skillData or {} + return { + chanceToTriggerOnCrit = skillData.chanceToTriggerOnCrit, + dpsMultiplier = skillData.dpsMultiplier, + LifeReservedPercent = skillData.LifeReservedPercent, + ManaReservedPercent = skillData.ManaReservedPercent, + EnergyShieldReservedPercent = skillData.EnergyShieldReservedPercent, + RageReservedPercent = skillData.RageReservedPercent, + } +end + +local function buildCachedOutput(output) + local mainHand = output and output.MainHand or nil + local offHand = output and output.OffHand or nil + return { + Speed = output and output.Speed or 0, + HitSpeed = output and output.HitSpeed or 0, + Duration = output and output.Duration or 0, + Time = output and output.Time or 0, + HitTime = output and output.HitTime or 0, + ManaCost = output and output.ManaCost or 0, + LifeCost = output and output.LifeCost or 0, + ESCost = output and output.ESCost or 0, + RageCost = output and output.RageCost or 0, + ManaCostRaw = output and output.ManaCostRaw or 0, + LifePercentCost = output and output.LifePercentCost or 0, + ManaPercentCost = output and output.ManaPercentCost or 0, + ProjectileCount = output and output.ProjectileCount or 0, + BattleCryExertsCount = output and output.BattleCryExertsCount or 0, + BattleMageCryDuration = output and output.BattleMageCryDuration or 0, + BattleMageCryCastTime = output and output.BattleMageCryCastTime or 0, + BattleMageCryCooldown = output and output.BattleMageCryCooldown or 0, + BattlemageUpTimeRatio = output and output.BattlemageUpTimeRatio or 0, + InfernalExertsCount = output and output.InfernalExertsCount or 0, + InfernalCryDuration = output and output.InfernalCryDuration or 0, + InfernalCryCastTime = output and output.InfernalCryCastTime or 0, + InfernalCryCooldown = output and output.InfernalCryCooldown or 0, + InfernalUpTimeRatio = output and output.InfernalUpTimeRatio or 0, + TotemLife = output and output.TotemLife or 0, + MainHand = { + HitChance = mainHand and mainHand.HitChance or 0, + CritChance = mainHand and mainHand.CritChance or 0, + }, + OffHand = { + HitChance = offHand and offHand.HitChance or 0, + CritChance = offHand and offHand.CritChance or 0, + }, + } +end + +function buildSafeSkillCacheEntry(uuid, skill, debugNote) + local entry = { + Name = skill and skill.activeEffect and skill.activeEffect.grantedEffect.name or "Unknown", + Speed = 0, + HitSpeed = 0, + ManaCost = 0, + LifeCost = 0, + ESCost = 0, + RageCost = 0, + HitChance = 0, + AccuracyHitChance = 0, + PreEffectiveCritChance = 0, + CritChance = 0, + TotalDPS = 0, + Output = buildCachedOutput(nil), + MainSkillData = skill and buildCachedMainSkillData(skill) or {}, + SkillUUID = uuid, + Incomplete = true, + DebugNote = debugNote, + } + return entry +end + -- Global Cache related function cacheData(uuid, env) - GlobalCache.cachedData[env.mode][uuid] = { - Name = env.player.mainSkill.activeEffect.grantedEffect.name, - Speed = env.player.output.Speed, - HitSpeed = env.player.output.HitSpeed, - ManaCost = env.player.output.ManaCost, - LifeCost = env.player.output.LifeCost, - ESCost = env.player.output.ESCost, - RageCost = env.player.output.RageCost, - HitChance = env.player.output.HitChance, - AccuracyHitChance = env.player.output.AccuracyHitChance, - PreEffectiveCritChance = env.player.output.PreEffectiveCritChance, - CritChance = env.player.output.CritChance, - TotalDPS = env.player.output.TotalDPS, - ActiveSkill = env.player.mainSkill, - Env = env, + local mainSkill = env.player.mainSkill + local output = env.player.output + getGlobalCacheTable(env)[uuid] = { + Name = mainSkill.activeEffect.grantedEffect.name, + Speed = output.Speed, + HitSpeed = output.HitSpeed, + ManaCost = output.ManaCost, + LifeCost = output.LifeCost, + ESCost = output.ESCost, + RageCost = output.RageCost, + HitChance = output.HitChance, + AccuracyHitChance = output.AccuracyHitChance, + PreEffectiveCritChance = output.PreEffectiveCritChance, + CritChance = output.CritChance, + TotalDPS = output.TotalDPS, + Output = buildCachedOutput(output), + MainSkillData = buildCachedMainSkillData(mainSkill), + SkillUUID = uuid, } end diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 52ea0343ae..838ac069fb 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -2282,7 +2282,7 @@ Huge sets the radius to 11. local mod = mods[i] if mod then - mod = modLib.setSource(mod, source) + mod = modLib.withSource(mod, source) modList:AddMod(mod) end end diff --git a/src/Modules/ModTools.lua b/src/Modules/ModTools.lua index 781008ff22..005de2bdaf 100644 --- a/src/Modules/ModTools.lua +++ b/src/Modules/ModTools.lua @@ -214,6 +214,18 @@ function modLib.formatSourceMod(mod) return s_format("%s|%s|%s", modLib.formatValue(mod.value), mod.source, modLib.formatModParams(mod)) end +function modLib.withSource(mod, source) + local copy = copyTableSafe(mod, false) + copy.source = source + if type(copy.value) == "table" and copy.value.mod then + copy.value.mod.source = source + end + return copy +end + +-- Deprecated: internal-only helper that mutates the provided mod in place. +-- Only use this when the caller owns the modifier instance and no shared parser/cache +-- tables can observe the mutation; otherwise prefer withSource(). function modLib.setSource(mod, source) mod.source = source if type(mod.value) == "table" and mod.value.mod then @@ -230,8 +242,8 @@ function modLib.mergeKeystones(env, modDB) env.keystonesAdded[modObj.value] = true local fromTree = modObj.mod.source and not modObj.mod.source:lower():match("tree") for _, mod in ipairs(env.spec.tree.keystoneMap[modObj.value].modList) do - modDB:AddMod(fromTree and modLib.setSource(mod, modObj.mod.source) or mod) + modDB:AddMod(fromTree and modLib.withSource(mod, modObj.mod.source) or mod) end end end -end \ No newline at end of file +end