diff --git a/spec/System/TestCommon_spec.lua b/spec/System/TestCommon_spec.lua index 4ff1906d83..8e5bf3b838 100644 --- a/spec/System/TestCommon_spec.lua +++ b/spec/System/TestCommon_spec.lua @@ -66,31 +66,32 @@ describe("Common", function() new("StupidClass"):StupidClass() end, "Class StupidClass constructor did not return a value") end) - it("produces an error if its constructor has not been called", function() - local StupidClass = newClass("StupidClass") - function StupidClass:StupidClass() - return self - end + -- disabled for performance reasons for now + -- it("produces an error if its constructor has not been called", function() + -- local StupidClass = newClass("StupidClass") + -- function StupidClass:StupidClass() + -- return self + -- end - function StupidClass:Clear() - end + -- function StupidClass:Clear() + -- end - common.classes.StupidClass = StupidClass + -- common.classes.StupidClass = StupidClass - assert.has_error(function() - local object = new("StupidClass") - return object.lines - end) - assert.has_error(function() - local object = new("StupidClass") - object:Clear() - end) - assert.has_no.errors(function() - local object = new("StupidClass"):StupidClass() - local x = object.lines - object:Clear() - end) - common.classes.StupidClass = nil - end) + -- assert.has_error(function() + -- local object = new("StupidClass") + -- return object.lines + -- end) + -- assert.has_error(function() + -- local object = new("StupidClass") + -- object:Clear() + -- end) + -- assert.has_no.errors(function() + -- local object = new("StupidClass"):StupidClass() + -- local x = object.lines + -- object:Clear() + -- end) + -- common.classes.StupidClass = nil + -- end) end) end) \ No newline at end of file diff --git a/src/Classes/CalcBreakdownControl.lua b/src/Classes/CalcBreakdownControl.lua index 3035f9bdc6..cfb1e8b5c1 100644 --- a/src/Classes/CalcBreakdownControl.lua +++ b/src/Classes/CalcBreakdownControl.lua @@ -312,7 +312,13 @@ function CalcBreakdownClass:AddModSection(sectionData, modList) rowList = copyTable(modList) else if type(sectionData.modName) == "table" then - rowList = modStore:Tabulate(sectionData.modType, cfg, unpack(sectionData.modName)) + rowList = {} + for _, mod in ipairs(sectionData.modName) do + local mods = modStore:Tabulate(sectionData.modType, cfg, mod) + for _, mod in ipairs(mods) do + table.insert(rowList, mod) + end + end else rowList = modStore:Tabulate(sectionData.modType, cfg, sectionData.modName) end diff --git a/src/Classes/CompareCalcsHelpers.lua b/src/Classes/CompareCalcsHelpers.lua index 1a5254e8d0..c1a5e6bb06 100644 --- a/src/Classes/CompareCalcsHelpers.lua +++ b/src/Classes/CompareCalcsHelpers.lua @@ -102,7 +102,13 @@ function M.TabulateMods(sectionData, actor) local rowList if type(sectionData.modName) == "table" then - rowList = modStore:Tabulate(sectionData.modType, cfg, unpack(sectionData.modName)) + rowList = { } + for index = 1, #sectionData.modName, 8 do + local rows = modStore:Tabulate(sectionData.modType, cfg, unpack(sectionData.modName, index, math.min(index + 7, #sectionData.modName))) + for _, row in ipairs(rows) do + t_insert(rowList, row) + end + end else rowList = modStore:Tabulate(sectionData.modType, cfg, sectionData.modName) end diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 9cd49c6ce5..6fe319f3b4 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -236,7 +236,7 @@ function GemSelectClass:BuildList(buf) end function GemSelectClass:UpdateSortCache() - --local start = GetTime() + local start = GetTime() local sortCache = self.sortCache local sameSortBy = self.sortGemsBy == self.lastSortGemsBy -- Don't update the cache if no settings have changed that would impact the ordering @@ -270,7 +270,8 @@ function GemSelectClass:UpdateSortCache() canSupport = { }, dps = { }, dpsColor = { }, - sortType = self.skillsTab.sortGemsByDPSField + sortType = self.skillsTab.sortGemsByDPSField, + startTime = start, } self.sortCache = sortCache @@ -426,6 +427,7 @@ function GemSelectClass:DPSBuilder() end self:SortCurrentList() + --ConPrintf("Gem Selector time: %d ms", GetTime() - sortCache.startTime) sortCache.pendingGems = nil end diff --git a/src/Classes/ModDB.lua b/src/Classes/ModDB.lua index 71e9779396..258360a648 100644 --- a/src/Classes/ModDB.lua +++ b/src/Classes/ModDB.lua @@ -134,11 +134,60 @@ function ModDBClass:AddDB(modDB) end end -function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, source, ...) +function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, source, modName) local result = 0 local globalLimits - for i = 1, select('#', ...) do - local modList = self.mods[select(i, ...)] + local modList = self.mods[modName] + if modList then + for i = 1, #modList do + local mod = modList[i] + if mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or (mod.source and (mod.source:match("[^:]+") == source or mod.source == source))) then + if mod[1] then + if not globalLimits then + globalLimits = {} + end + local value = context:EvalMod(mod, cfg, globalLimits) or 0 + result = result + value + else + result = result + mod.value + end + end + end + end + if self.parent then + result = result + self.parent:SumInternal(context, modType, cfg, flags, keywordFlags, source, modName) + end + return result +end + +-- essentially select(i, ...), except this will not abort JIT traces +local function nameAt(i, n1, n2, n3, n4, n5, n6, n7, n8) + if i == 1 then + return n1 + elseif i == 2 then + return n2 + elseif i == 3 then + return n3 + elseif i == 4 then + return n4 + elseif i == 5 then + return n5 + elseif i == 6 then + return n6 + elseif i == 7 then + return n7 + elseif i == 8 then + return n8 + end + error("mod queries support at most 8 names") +end + +function ModDBClass:SumInternalMulti(context, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + local result = 0 + local globalLimits + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modList = self.mods[modName] if modList then for i = 1, #modList do local mod = modList[i] @@ -157,18 +206,59 @@ function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, sour end end if self.parent then - result = result + self.parent:SumInternal(context, modType, cfg, flags, keywordFlags, source, ...) + result = result + self.parent:SumInternalMulti(context, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end return result end -function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) +function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, modName) local result = 1 local modPrecision = nil local globalLimits - for i = 1, select('#', ...) do - local modList = self.mods[select(i, ...)] - local modResult = 1 --The more multipliers for each mod are computed to the nearest percent then applied. + local modList = self.mods[modName] + local modResult = 1 + if modList then + for i = 1, #modList do + local mod = modList[i] + if mod.type == "MORE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + local value + if mod[1] then + if not globalLimits then + globalLimits = {} + end + value = context:EvalMod(mod, cfg, globalLimits) or 0 + else + value = mod.value or 0 + end + modResult = modResult * (1 + value / 100) + if modPrecision then + modPrecision = m_max(modPrecision, (data.highPrecisionMods[mod.name] and data.highPrecisionMods[mod.name][mod.type]) or modPrecision) + else + modPrecision = (data.highPrecisionMods[mod.name] and data.highPrecisionMods[mod.name][mod.type]) or nil + end + end + end + end + if modPrecision then + local power = 10 ^ modPrecision + result = math.floor(result * modResult * power) / power + else + result = result * round(modResult, 2) + end + if self.parent then + result = result * self.parent:MoreInternal(context, cfg, flags, keywordFlags, source, modName) + end + return result +end + +function ModDBClass:MoreInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + local result = 1 + local modPrecision = nil + local globalLimits + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modList = self.mods[modName] + local modResult = 1 if modList then for i = 1, #modList do local mod = modList[i] @@ -199,14 +289,37 @@ function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) end end if self.parent then - result = result * self.parent:MoreInternal(context, cfg, flags, keywordFlags, source, ...) + result = result * self.parent:MoreInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end return result end -function ModDBClass:FlagInternal(context, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modList = self.mods[select(i, ...)] +function ModDBClass:FlagInternal(context, cfg, flags, keywordFlags, source, modName) + local modList = self.mods[modName] + if modList then + for i = 1, #modList do + local mod = modList[i] + local checkSource = not cfg or not cfg.ignoreSourceInCheckConditions + if mod.type == "FLAG" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not checkSource or not source or mod.source:match("[^:]+") == source) then + if mod[1] then + if context:EvalMod(mod, cfg) then + return true + end + elseif mod.value then + return true + end + end + end + end + if self.parent then + return self.parent:FlagInternal(context, cfg, flags, keywordFlags, source, modName) + end +end + +function ModDBClass:FlagInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modList = self.mods[modName] if modList then for i = 1, #modList do local mod = modList[i] @@ -224,13 +337,36 @@ function ModDBClass:FlagInternal(context, cfg, flags, keywordFlags, source, ...) end end if self.parent then - return self.parent:FlagInternal(context, cfg, flags, keywordFlags, source, ...) + return self.parent:FlagInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end -function ModDBClass:OverrideInternal(context, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modList = self.mods[select(i, ...)] +function ModDBClass:OverrideInternal(context, cfg, flags, keywordFlags, source, modName) + local modList = self.mods[modName] + if modList then + for i = 1, #modList do + local mod = modList[i] + if mod.type == "OVERRIDE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + local value = context:EvalMod(mod, cfg) + if value then + return value + end + elseif mod.value then + return mod.value + end + end + end + end + if self.parent then + return self.parent:OverrideInternal(context, cfg, flags, keywordFlags, source, modName) + end +end + +function ModDBClass:OverrideInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modList = self.mods[modName] if modList then for i = 1, #modList do local mod = modList[i] @@ -248,18 +384,40 @@ function ModDBClass:OverrideInternal(context, cfg, flags, keywordFlags, source, end end if self.parent then - return self.parent:OverrideInternal(context, cfg, flags, keywordFlags, source, ...) + return self.parent:OverrideInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + end +end + +function ModDBClass:ListInternal(context, result, cfg, flags, keywordFlags, source, modName) + local modList = self.mods[modName] + if modList then + for i = 1, #modList do + local mod = modList[i] + if mod.type == "LIST" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + local value = context:EvalMod(mod, cfg) or nullValue + if value then + t_insert(result, value) + end + elseif mod.value then + t_insert(result, mod.value) + end + end + end + end + if self.parent then + self.parent:ListInternal(context, result, cfg, flags, keywordFlags, source, modName) end end -function ModDBClass:ListInternal(context, result, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modList = self.mods[select(i, ...)] +function ModDBClass:ListInternalMulti(context, result, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modList = self.mods[modName] if modList then for i = 1, #modList do local mod = modList[i] if mod.type == "LIST" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then - local value if mod[1] then local value = context:EvalMod(mod, cfg) or nullValue if value then @@ -273,14 +431,41 @@ function ModDBClass:ListInternal(context, result, cfg, flags, keywordFlags, sour end end if self.parent then - self.parent:ListInternal(context, result, cfg, flags, keywordFlags, source, ...) + self.parent:ListInternalMulti(context, result, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end -function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, ...) +function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, modName) local globalLimits - for i = 1, select('#', ...) do - local modName = select(i, ...) + local modList = self.mods[modName] + if modList then + for i = 1, #modList do + local mod = modList[i] + if (mod.type == modType or not modType) and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + local value + if mod[1] then + if not globalLimits then + globalLimits = {} + end + value = context:EvalMod(mod, cfg, globalLimits) + else + value = mod.value + end + if value and (value ~= 0 or mod.type == "OVERRIDE") then + t_insert(result, { value = value, mod = mod }) + end + end + end + end + if self.parent then + self.parent:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, modName) + end +end + +function ModDBClass:TabulateInternalMulti(context, result, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + local globalLimits + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) local modList = self.mods[modName] if modList then for i = 1, #modList do @@ -303,20 +488,32 @@ function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywo end end if self.parent then - self.parent:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, ...) + self.parent:TabulateInternalMulti(context, result, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end ----HasModInternal ---- Checks if a mod exists with the given properties ----@param modType string @The type of the mod, e.g. "BASE" ----@param flags number @The mod flags to match ----@param keywordFlags number @The mod keyword flags to match ----@param source string @The mod source to match ----@return boolean @true if the mod is found, false otherwise. -function ModDBClass:HasModInternal(modType, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modList = self.mods[select(i, ...)] +function ModDBClass:HasModInternal(modType, flags, keywordFlags, source, modName) + local modList = self.mods[modName] + if modList then + for i = 1, #modList do + local mod = modList[i] + if mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + return true + end + end + end + if self.parent then + if self.parent:HasModInternal(modType, flags, keywordFlags, source, modName) == true then + return true + end + end + return false +end + +function ModDBClass:HasModInternalMulti(modType, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modList = self.mods[modName] if modList then for i = 1, #modList do local mod = modList[i] @@ -327,8 +524,7 @@ function ModDBClass:HasModInternal(modType, flags, keywordFlags, source, ...) end end if self.parent then - local parentResult = self.parent:HasModInternal(modType, flags, keywordFlags, source, ...) - if parentResult == true then + if self.parent:HasModInternalMulti(modType, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) == true then return true end end diff --git a/src/Classes/ModList.lua b/src/Classes/ModList.lua index f4aa84ffb9..aca0f344c5 100644 --- a/src/Classes/ModList.lua +++ b/src/Classes/ModList.lua @@ -100,10 +100,50 @@ function ModListClass:MergeNewMod(...) end -function ModListClass:SumInternal(context, modType, cfg, flags, keywordFlags, source, ...) +-- essentially select(i, ...), except this will not abort JIT traces. +local function nameAt(i, n1, n2, n3, n4, n5, n6, n7, n8) + if i == 1 then + return n1 + elseif i == 2 then + return n2 + elseif i == 3 then + return n3 + elseif i == 4 then + return n4 + elseif i == 5 then + return n5 + elseif i == 6 then + return n6 + elseif i == 7 then + return n7 + elseif i == 8 then + return n8 + end + error("mod queries support at most 8 names") +end + +function ModListClass:SumInternal(context, modType, cfg, flags, keywordFlags, source, modName) local result = 0 - for i = 1, select('#', ...) do - local modName = select(i, ...) + for i = 1, #self do + local mod = self[i] + if mod.name == modName and mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + result = result + (context:EvalMod(mod, cfg) or 0) + else + result = result + mod.value + end + end + end + if self.parent then + result = result + self.parent:SumInternal(context, modType, cfg, flags, keywordFlags, source, modName) + end + return result +end + +function ModListClass:SumInternalMulti(context, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + local result = 0 + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) for i = 1, #self do local mod = self[i] if mod.name == modName and mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then @@ -116,17 +156,48 @@ function ModListClass:SumInternal(context, modType, cfg, flags, keywordFlags, so end end if self.parent then - result = result + self.parent:SumInternal(context, modType, cfg, flags, keywordFlags, source, ...) + result = result + self.parent:SumInternalMulti(context, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + end + return result +end + +function ModListClass:MoreInternal(context, cfg, flags, keywordFlags, source, modName) + local result = 1 + local modPrecision = nil + local modResult = 1 + for i = 1, #self do + local mod = self[i] + if mod.name == modName and mod.type == "MORE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + modResult = modResult * (1 + (context:EvalMod(mod, cfg) or 0) / 100) + else + modResult = modResult * (1 + mod.value / 100) + end + if modPrecision then + modPrecision = m_max(modPrecision, (data.highPrecisionMods[mod.name] and data.highPrecisionMods[mod.name][mod.type]) or modPrecision) + else + modPrecision = (data.highPrecisionMods[mod.name] and data.highPrecisionMods[mod.name][mod.type]) or nil + end + end + end + if modPrecision then + local power = 10 ^ modPrecision + result = math.floor(result * modResult * power) / power + else + result = result * round(modResult, 2) + end + if self.parent then + result = result * self.parent:MoreInternal(context, cfg, flags, keywordFlags, source, modName) end return result end -function ModListClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) +function ModListClass:MoreInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) local result = 1 local modPrecision = nil - for i = 1, select('#', ...) do - local modResult = 1 --The more multipliers for each mod are computed to the nearest percent then applied. - local modName = select(i, ...) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) + local modResult = 1 for i = 1, #self do local mod = self[i] if mod.name == modName and mod.type == "MORE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then @@ -150,14 +221,32 @@ function ModListClass:MoreInternal(context, cfg, flags, keywordFlags, source, .. end end if self.parent then - result = result * self.parent:MoreInternal(context, cfg, flags, keywordFlags, source, ...) + result = result * self.parent:MoreInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end return result end -function ModListClass:FlagInternal(context, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modName = select(i, ...) +function ModListClass:FlagInternal(context, cfg, flags, keywordFlags, source, modName) + for i = 1, #self do + local mod = self[i] + if mod.name == modName and mod.type == "FLAG" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + if context:EvalMod(mod, cfg) then + return true + end + elseif mod.value then + return true + end + end + end + if self.parent then + return self.parent:FlagInternal(context, cfg, flags, keywordFlags, source, modName) + end +end + +function ModListClass:FlagInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) for i = 1, #self do local mod = self[i] if mod.name == modName and mod.type == "FLAG" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then @@ -172,13 +261,32 @@ function ModListClass:FlagInternal(context, cfg, flags, keywordFlags, source, .. end end if self.parent then - return self.parent:FlagInternal(context, cfg, flags, keywordFlags, source, ...) + return self.parent:FlagInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end -function ModListClass:OverrideInternal(context, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modName = select(i, ...) +function ModListClass:OverrideInternal(context, cfg, flags, keywordFlags, source, modName) + for i = 1, #self do + local mod = self[i] + if mod.name == modName and mod.type == "OVERRIDE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + local value = context:EvalMod(mod, cfg) + if value then + return value + end + elseif mod.value then + return mod.value + end + end + end + if self.parent then + return self.parent:OverrideInternal(context, cfg, flags, keywordFlags, source, modName) + end +end + +function ModListClass:OverrideInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) for i = 1, #self do local mod = self[i] if mod.name == modName and mod.type == "OVERRIDE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then @@ -194,17 +302,35 @@ function ModListClass:OverrideInternal(context, cfg, flags, keywordFlags, source end end if self.parent then - return self.parent:OverrideInternal(context, cfg, flags, keywordFlags, source, ...) + return self.parent:OverrideInternalMulti(context, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end -function ModListClass:ListInternal(context, result, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modName = select(i, ...) +function ModListClass:ListInternal(context, result, cfg, flags, keywordFlags, source, modName) + for i = 1, #self do + local mod = self[i] + if mod.name == modName and mod.type == "LIST" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + if mod[1] then + local value = context:EvalMod(mod, cfg) or nullValue + if value then + t_insert(result, value) + end + elseif mod.value then + t_insert(result, mod.value) + end + end + end + if self.parent then + self.parent:ListInternal(context, result, cfg, flags, keywordFlags, source, modName) + end +end + +function ModListClass:ListInternalMulti(context, result, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) for i = 1, #self do local mod = self[i] if mod.name == modName and mod.type == "LIST" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then - local value if mod[1] then local value = context:EvalMod(mod, cfg) or nullValue if value then @@ -217,13 +343,33 @@ function ModListClass:ListInternal(context, result, cfg, flags, keywordFlags, so end end if self.parent then - self.parent:ListInternal(context, result, cfg, flags, keywordFlags, source, ...) + self.parent:ListInternalMulti(context, result, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end -function ModListClass:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modName = select(i, ...) +function ModListClass:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, modName) + for i = 1, #self do + local mod = self[i] + if mod.name == modName and (mod.type == modType or not modType) and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + local value + if mod[1] then + value = context:EvalMod(mod, cfg) + else + value = mod.value + end + if value and (value ~= 0 or mod.type == "OVERRIDE") then + t_insert(result, { value = value, mod = mod }) + end + end + end + if self.parent then + self.parent:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, modName) + end +end + +function ModListClass:TabulateInternalMulti(context, result, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) for i = 1, #self do local mod = self[i] if mod.name == modName and (mod.type == modType or not modType) and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then @@ -240,20 +386,27 @@ function ModListClass:TabulateInternal(context, result, modType, cfg, flags, key end end if self.parent then - self.parent:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, ...) + self.parent:TabulateInternalMulti(context, result, modType, cfg, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) end end +function ModListClass:HasModInternal(modType, flags, keywordFlags, source, modName) + for i = 1, #self do + local mod = self[i] + if mod.name == modName and mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then + return true + end + end + if self.parent then + if self.parent:HasModInternal(modType, flags, keywordFlags, source, modName) == true then + return true + end + end + return false +end ----HasModInternal ---- Checks if a mod exists with the given properties ----@param modType string @The type of the mod, e.g. "BASE" ----@param flags number @The mod flags to match ----@param keywordFlags number @The mod keyword flags to match ----@param source string @The mod source to match ----@return boolean @true if the mod is found, false otherwise. -function ModListClass:HasModInternal(modType, flags, keywordFlags, source, ...) - for i = 1, select('#', ...) do - local modName = select(i, ...) +function ModListClass:HasModInternalMulti(modType, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) + for nameIndex = 1, argCount do + local modName = nameAt(nameIndex, n1, n2, n3, n4, n5, n6, n7, n8) for i = 1, #self do local mod = self[i] if mod.name == modName and mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then @@ -262,8 +415,7 @@ function ModListClass:HasModInternal(modType, flags, keywordFlags, source, ...) end end if self.parent then - local parentResult = self.parent:HasModInternal(modType, flags, keywordFlags, source, ...) - if parentResult == true then + if self.parent:HasModInternalMulti(modType, flags, keywordFlags, source, argCount, n1, n2, n3, n4, n5, n6, n7, n8) == true then return true end end diff --git a/src/Classes/ModStore.lua b/src/Classes/ModStore.lua index 033df4967a..305b4cb74a 100644 --- a/src/Classes/ModStore.lua +++ b/src/Classes/ModStore.lua @@ -33,9 +33,11 @@ end }) ---@field keywordFlags number? ---@field skillName string? ---@field source string? + ---@class TabulatedMod ---@field value any ---@field mod Mod + ---@class ModStore ---@field ScaleAddMod fun(self: ModStore, mod: Mod, scale: number, roundToNearest?: boolean) ---@field CopyList fun(self: ModStore, modList: Mod[]) @@ -195,7 +197,7 @@ end ---@param modType NumericModTypes ---@param cfg? ModCfg ----@param ... string +---@param ... string Mod names to query. Maximum 8 names due to JIT performance concerns. ---@return number function ModStoreClass:Sum(modType, cfg, ...) local flags, keywordFlags = 0, 0 @@ -205,7 +207,13 @@ function ModStoreClass:Sum(modType, cfg, ...) keywordFlags = cfg.keywordFlags or 0 source = cfg.source end - return self:SumInternal(self, modType, cfg, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + return self:SumInternal(self, modType, cfg, flags, keywordFlags, source, arg) + end + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + return self:SumInternalMulti(self, modType, cfg, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) end @@ -248,7 +256,7 @@ function ModStoreClass:SumNegativeValues(modType, cfg, modName, ...) end ---@param cfg? ModCfg ----@param ... string +---@param ... string Mod names to query. Maximum 8 names due to JIT performance concerns. ---@return number function ModStoreClass:More(cfg, ...) local flags, keywordFlags = 0, 0 @@ -258,7 +266,13 @@ function ModStoreClass:More(cfg, ...) keywordFlags = cfg.keywordFlags or 0 source = cfg.source end - return self:MoreInternal(self, cfg, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + return self:MoreInternal(self, cfg, flags, keywordFlags, source, arg) + end + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + return self:MoreInternalMulti(self, cfg, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) end ---@param cfg? ModCfg @@ -272,7 +286,13 @@ function ModStoreClass:Flag(cfg, ...) keywordFlags = cfg.keywordFlags or 0 source = cfg.source end - return self:FlagInternal(self, cfg, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + return self:FlagInternal(self, cfg, flags, keywordFlags, source, arg) + end + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + return self:FlagInternalMulti(self, cfg, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) end ---@param cfg? ModCfg @@ -286,7 +306,13 @@ function ModStoreClass:Override(cfg, ...) keywordFlags = cfg.keywordFlags or 0 source = cfg.source end - return self:OverrideInternal(self, cfg, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + return self:OverrideInternal(self, cfg, flags, keywordFlags, source, arg) + end + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + return self:OverrideInternalMulti(self, cfg, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) end ---@param cfg? ModCfg @@ -301,13 +327,20 @@ function ModStoreClass:List(cfg, ...) source = cfg.source end local result = { } - self:ListInternal(self, result, cfg, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + self:ListInternal(self, result, cfg, flags, keywordFlags, source, arg) + else + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + self:ListInternalMulti(self, result, cfg, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) + end return result end ---@param modType? NumericModTypes|"FLAG"|"LIST" ---@param cfg? ModCfg ----@param ... string +---@param ... string Mod names to query. Maximum 8 names due to JIT performance concerns. ---@return TabulatedMod[] function ModStoreClass:Tabulate(modType, cfg, ...) local flags, keywordFlags = 0, 0 @@ -319,7 +352,14 @@ function ModStoreClass:Tabulate(modType, cfg, ...) end ---@type TabulatedMod[] local result = { } - self:TabulateInternal(self, result, modType, cfg, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + self:TabulateInternal(self, result, modType, cfg, flags, keywordFlags, source, arg) + else + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + self:TabulateInternalMulti(self, result, modType, cfg, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) + end return result end @@ -341,10 +381,10 @@ end --- Checks if a mod exists with the given properties. --- Useful for determining if the other aggregate functions will find --- anything to aggregate. ----@param modType NumericModTypes|"FLAG"|"LIST" @Mod type to match ----@param cfg? ModCfg configuration to use - contains flags, keywordFlags, and source to match ----@param ... string @Mod name(s) to check for. ----@return boolean @true if the mod is found, false otherwise. +---@param modType NumericModTypes|"FLAG"|"LIST" Mod type to match +---@param cfg? ModCfg Configuration to use - contains flags, keywordFlags, and source to match +---@param ... string Mod names to query. Maximum 8 names due to JIT performance concerns. +---@return boolean result True if the mod is found, false otherwise. function ModStoreClass:HasMod(modType, cfg, ...) local flags, keywordFlags = 0, 0 local source @@ -353,7 +393,13 @@ function ModStoreClass:HasMod(modType, cfg, ...) keywordFlags = cfg.keywordFlags or 0 source = cfg.source end - return self:HasModInternal(modType, flags, keywordFlags, source, ...) + local n = select('#', ...) + if n == 1 then + local arg = ... + return self:HasModInternal(modType, flags, keywordFlags, source, arg) + end + local n1, n2, n3, n4, n5, n6, n7, n8 = ... + return self:HasModInternalMulti(modType, flags, keywordFlags, source, n, n1, n2, n3, n4, n5, n6, n7, n8) end ---@param var string @@ -424,6 +470,19 @@ function ModStoreClass:GetStat(stat, cfg) end end +local function upperFirst(a, b) + return string.upper(a) .. b +end + +local function isValidSocket(sockets, targetSocket) + for _, val in ipairs(sockets) do + if val == targetSocket then + return true + end + end + return false +end + ---@param mod Mod ---@param cfg? ModCfg ---@param globalLimits? table @@ -740,7 +799,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end elseif tag.type == "ItemCondition" then local matches = {} - local itemSlot = tag.itemSlot:lower():gsub("(%l)(%w*)", function(a,b) return string.upper(a)..b end):gsub('^%s*(.-)%s*$', '%1') + local itemSlot = tag.itemSlot:lower():gsub("(%l)(%w*)", upperFirst):gsub('^%s*(.-)%s*$', '%1') local items = {} if tag.allSlots then items = self.actor.itemList @@ -799,15 +858,6 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) if not cfg or (not tag.slotName and not tag.keyword and not tag.socketColor and not tag.slotType) then return else - local function isValidSocket(sockets, targetSocket) - for _, val in ipairs(sockets) do - if val == targetSocket then - return true - end - end - return false - end - local match = {} if tag.slotType then match["slotType"] = true -- implemented in CalcSetup.lua @@ -1019,7 +1069,8 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end -- Apply global limits - for _, tag in ipairs(mod) do + for i = 1, #mod do + local tag = mod[i] if globalLimits and tag.globalLimit and tag.globalLimitKey then value = value or 0 globalLimits[tag.globalLimitKey] = globalLimits[tag.globalLimitKey] or 0 diff --git a/src/Data/Global.lua b/src/Data/Global.lua index 3d410f8d5e..f59c3d588d 100644 --- a/src/Data/Global.lua +++ b/src/Data/Global.lua @@ -117,91 +117,88 @@ function colorCodeToMarkupColour(code) local b = tonumber(code:sub(5, 6), 16) return string.format("", r, g, b) end --- NOTE: the LuaJIT bitwise operations we have are not 64-bit --- so we need to implement them ourselves. Lua uses 53-bit doubles. +-- NOTE: the LuaJIT bitwise operations we have are not 64-bit for Lua numbers, which are doubles +-- (53-bit) so we need to implement them ourselves. We also cannot effectively use FFI `uint64_t` as +-- they would be boxed. local HIGH_MASK_53 = 0x1FFFFF -function OR64(...) - local args = {...} - if #args < 2 then - return args[1] or 0 - end - - -- Start with first value - local result = args[1] - - -- OR with each subsequent value - for i = 2, #args do - -- Split into high and low 32-bit parts - local ah = math.floor(result / 0x100000000) - local al = result % 0x100000000 - local bh = math.floor(args[i] / 0x100000000) - local bl = args[i] % 0x100000000 - - -- Perform OR operation on both parts - local high = bit.bor(ah, bh) - local low = bit.bor(al, bl) - - -- Combine the results - result = bit.band(high, HIGH_MASK_53) * 0x100000000 + low - end - - return result +-- Combining two 53-bit halves is done in an odd way as we have to do it while avoiding breaking +-- LuaJIT traces and pointless allocations. This code is often called in very hot loops. +local bit_band, bit_bor, bit_bxor = bit.band, bit.bor, bit.bxor +local m_floor = math.floor + +local function or2(a, b) + -- Split into high and low 32-bit parts and perform OR operation on both parts + local high = bit_bor(m_floor(a / 0x100000000), m_floor(b / 0x100000000)) + local low = bit_bor(a % 0x100000000, b % 0x100000000) + -- Combine the results + return bit_band(high, HIGH_MASK_53) * 0x100000000 + low end -function AND64(...) - local args = {...} - if #args < 2 then - return args[1] or 0 - end - - -- Start with first value - local result = args[1] - - -- AND with each subsequent value - for i = 2, #args do - -- Split into high and low 32-bit parts - local ah = math.floor(result / 0x100000000) - local al = result % 0x100000000 - local bh = math.floor(args[i] / 0x100000000) - local bl = args[i] % 0x100000000 - - -- Perform AND operation on both parts - local high = bit.band(ah, bh) - local low = bit.band(al, bl) - - -- Combine the results - result = bit.band(high, HIGH_MASK_53) * 0x100000000 + low - end - - return result +local function and2(a, b) + -- Split into high and low 32-bit parts and perform AND operation on both parts + local high = bit_band(m_floor(a / 0x100000000), m_floor(b / 0x100000000)) + local low = bit_band(a % 0x100000000, b % 0x100000000) + -- Combine the results + return bit_band(high, HIGH_MASK_53) * 0x100000000 + low end -function XOR64(...) - local args = {...} - if #args < 2 then - return args[1] or 0 - end - - -- Start with first value - local result = args[1] - - -- XOR with each subsequent value - for i = 2, #args do - -- Split into high and low 32-bit parts - local ah = math.floor(result / 0x100000000) - local al = result % 0x100000000 - local bh = math.floor(args[i] / 0x100000000) - local bl = args[i] % 0x100000000 +local function xor2(a, b) + -- Split into high and low 32-bit parts and perform XOR operation on both parts + local high = bit_bxor(m_floor(a / 0x100000000), m_floor(b / 0x100000000)) + local low = bit_bxor(a % 0x100000000, b % 0x100000000) + -- Combine the results + return bit_band(high, HIGH_MASK_53) * 0x100000000 + low +end - -- Perform XOR operation on both parts - local high = bit.bxor(ah, bh) - local low = bit.bxor(al, bl) +function OR64(a, b, c, d, e, f, g, h) + if b == nil then return a or 0 end + local r = or2(a, b) + if c == nil then return r end + r = or2(r, c) + if d == nil then return r end + r = or2(r, d) + if e == nil then return r end + r = or2(r, e) + if f == nil then return r end + r = or2(r, f) + if g == nil then return r end + r = or2(r, g) + if h == nil then return r end + return or2(r, h) +end - -- Combine the results - result = bit.band(high, HIGH_MASK_53) * 0x100000000 + low - end +function AND64(a, b, c, d, e, f, g, h) + if b == nil then return a or 0 end + local r = and2(a, b) + if c == nil then return r end + r = and2(r, c) + if d == nil then return r end + r = and2(r, d) + if e == nil then return r end + r = and2(r, e) + if f == nil then return r end + r = and2(r, f) + if g == nil then return r end + r = and2(r, g) + if h == nil then return r end + return and2(r, h) +end - return result +function XOR64(a, b, c, d, e, f, g, h) + if b == nil then return a or 0 end + local r = xor2(a, b) + if c == nil then return r end + r = xor2(r, c) + if d == nil then return r end + r = xor2(r, d) + if e == nil then return r end + r = xor2(r, e) + if f == nil then return r end + r = xor2(r, f) + if g == nil then return r end + r = xor2(r, g) + if h == nil then return r end + return xor2(r, h) end function NOT64(a) @@ -313,31 +310,12 @@ local band = AND64 local bnot = NOT64 local MatchAllMask = bnot(KeywordFlag.MatchAll) --- Two-level numeric-key cache to avoid building string keys or allocating tables per call. -local matchKeywordFlagsCache = {} -function ClearMatchKeywordFlagsCache() - -- cheap full reset without reallocating the outer table - for k in pairs(matchKeywordFlagsCache) do - matchKeywordFlagsCache[k] = nil - end -end ---@param keywordFlags number The KeywordFlags to be compared to. ---@param modKeywordFlags number The KeywordFlags stored in the mod. ---@return boolean Whether the KeywordFlags in the mod are satisfied. function MatchKeywordFlags(keywordFlags, modKeywordFlags) -- Cache lookup - local row = matchKeywordFlagsCache[keywordFlags] - if row then - local cached = row[modKeywordFlags] - if cached ~= nil then - return cached - end - else - row = {} - matchKeywordFlagsCache[keywordFlags] = row - end - -- Not in cache, compute normally local matchAll = band(modKeywordFlags, KeywordFlag.MatchAll) ~= 0 local modMasked = band(modKeywordFlags, MatchAllMask) local keywordMasked = band(keywordFlags, MatchAllMask) @@ -348,7 +326,7 @@ function MatchKeywordFlags(keywordFlags, modKeywordFlags) else matches = (modMasked == 0) or (band(keywordMasked, modMasked) ~= 0) end - row[modKeywordFlags] = matches -- Add to cache + -- row[modKeywordFlags] = matches -- Add to cache return matches end diff --git a/src/Launch.lua b/src/Launch.lua index 19051b1d0c..c9b3c31fba 100644 --- a/src/Launch.lua +++ b/src/Launch.lua @@ -16,7 +16,7 @@ ConExecute("set vid_resizable 3") ---@diagnostic disable-next-line: lowercase-global launch = { } SetMainObject(launch) -jit.opt.start('maxtrace=4000','maxmcode=8192') +jit.opt.start('maxtrace=20000', 'maxmcode=8192') collectgarbage("setpause", 400) function launch:OnInit() diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index d432524c38..8526eeff9e 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -77,18 +77,7 @@ local function isGlobalEffect(modOrGroup) end return false end - --- Merge skill effect modifiers with given mod list --- If a stat set is provided, merge it and global effects from the other stat sets -function calcs.mergeSkillInstanceMods(env, modList, skillEffect, statSet, extraStats) - calcLib.validateGemLevel(skillEffect) - -- Verify that statSet provided is from skillEffect - if statSet and not isValueInArray(skillEffect.grantedEffect.statSets, statSet) then - return - end - local grantedEffect = skillEffect.grantedEffect - local selectedGlobalStats = { } - local function mergeStatSet(set, onlyGlobals) +local function mergeStatSet(set, onlyGlobals, skillEffect, grantedEffect, env, extraStats, modList, selectedGlobalStats) local stats = calcLib.buildSkillInstanceStats(skillEffect, grantedEffect, set, env.useAltGemQualityStats) if extraStats and extraStats[1] then for _, stat in pairs(extraStats) do @@ -122,14 +111,24 @@ function calcs.mergeSkillInstanceMods(env, modList, skillEffect, statSet, extraS end end end +-- Merge skill effect modifiers with given mod list +-- If a stat set is provided, merge it and global effects from the other stat sets +function calcs.mergeSkillInstanceMods(env, modList, skillEffect, statSet, extraStats) + calcLib.validateGemLevel(skillEffect) + -- Verify that statSet provided is from skillEffect + if statSet and not isValueInArray(skillEffect.grantedEffect.statSets, statSet) then + return + end + local selectedGlobalStats = {} + local grantedEffect = skillEffect.grantedEffect for _, set in ipairs(statSet and {statSet} or grantedEffect.statSets) do - mergeStatSet(set) + mergeStatSet(set, nil, skillEffect, grantedEffect, env, extraStats, modList, selectedGlobalStats) modList:AddList(set.baseMods) end if statSet then for _, set in ipairs(grantedEffect.statSets) do if set ~= statSet then - mergeStatSet(set, true) + mergeStatSet(set, true, skillEffect, grantedEffect, env, extraStats, modList, selectedGlobalStats) for _, baseMod in ipairs(set.baseMods or { }) do if isGlobalEffect(baseMod) then modList:AddMod(baseMod) diff --git a/src/Modules/CalcDefence.lua b/src/Modules/CalcDefence.lua index 995cc322bd..f17b409def 100644 --- a/src/Modules/CalcDefence.lua +++ b/src/Modules/CalcDefence.lua @@ -423,6 +423,16 @@ function calcs.applyDmgTakenConversion(activeSkill, output, breakdown, sourceTyp end return damageBreakdown, totalDamageTaken end +local function damageMitigationMultiplierForType(output, modDB, damageType, damage, type) + local effectiveAppliedArmour = output[type .. "EffectiveAppliedArmour"] + local armourDRPercent = calcs.armourReductionF(effectiveAppliedArmour, damage) + local flatDRPercent = modDB:Flag(nil, "SelfIgnore" .. "Base" .. type .. "DamageReduction") and 0 or output["Base" .. type .. "DamageReductionWhenHit"] or output["Base" .. type .. "DamageReduction"] + local totalDRPercent = m_min(output[damageType .. "DamageReductionMax"], armourDRPercent + flatDRPercent) + local enemyOverwhelmPercent = modDB:Flag(nil, "SelfIgnore" .. type .. "DamageReduction") and 0 or output[type .. "EnemyOverwhelm"] + local totalDRMulti = 1 - m_max(m_min(output[damageType .. "DamageReductionMax"], totalDRPercent - enemyOverwhelmPercent), 0) / 100 + local totalResistMult = output[type .. "ResistTakenHitMulti"] + return totalResistMult * totalDRMulti +end ---Calculates the taken damages from enemy outgoing damage ---@param rawDamage number raw incoming damage number, after enemy damage multiplier @@ -433,16 +443,6 @@ function calcs.takenHitFromDamage(rawDamage, damageType, actor) ---@class Output local output = actor.output local modDB = actor.modDB - local function damageMitigationMultiplierForType(damage, type) - local effectiveAppliedArmour = output[type .."EffectiveAppliedArmour"] - local armourDRPercent = calcs.armourReductionF(effectiveAppliedArmour, damage) - local flatDRPercent = modDB:Flag(nil, "SelfIgnore".."Base".. type .."DamageReduction") and 0 or output["Base".. type .."DamageReductionWhenHit"] or output["Base".. type .."DamageReduction"] - local totalDRPercent = m_min(output[damageType.."DamageReductionMax"], armourDRPercent + flatDRPercent) - local enemyOverwhelmPercent = modDB:Flag(nil, "SelfIgnore".. type .."DamageReduction") and 0 or output[type .."EnemyOverwhelm"] - local totalDRMulti = 1 - m_max(m_min(output[damageType.."DamageReductionMax"], totalDRPercent - enemyOverwhelmPercent), 0) / 100 - local totalResistMult = output[type .."ResistTakenHitMulti"] - return totalResistMult * totalDRMulti - end local receivedDamageSum = 0 local damages = { } for damageConvertedType, convertPercent in pairs(actor.damageShiftTable[damageType]) do @@ -450,7 +450,7 @@ function calcs.takenHitFromDamage(rawDamage, damageType, actor) if convertPercent > 0 or takenFlat ~= 0 then local convertedDamage = rawDamage * convertPercent / 100 local vaalArctic = m_min(-modDB:Sum("MORE", nil, "VaalArcticArmourMitigation") / 100, 1) - local reducedDamage = round(m_max(convertedDamage * damageMitigationMultiplierForType(convertedDamage, damageConvertedType) + takenFlat, 0) * output[damageConvertedType .."AfterReductionTakenHitMulti"]) * (1 - vaalArctic) + local reducedDamage = round(m_max(convertedDamage * damageMitigationMultiplierForType(output, modDB, damageType, convertedDamage, damageConvertedType) + takenFlat, 0) * output[damageConvertedType .. "AfterReductionTakenHitMulti"]) * (1 - vaalArctic) receivedDamageSum = receivedDamageSum + reducedDamage damages[damageConvertedType] = (reducedDamage > 0 or convertPercent > 0) and reducedDamage or nil end @@ -770,6 +770,21 @@ local function incomingDamageBreakdown(breakdownTable, poolsRemaining, output) return breakdownTable end +local function calcRecoup(output, breakdown, modDB, recoup, recoupType, damageType) + output[damageType .. recoupType .. "Recoup"] = recoup * output[recoupType .. "RecoveryRateMod"] + output["anyRecoup"] = output["anyRecoup"] + output[damageType .. recoupType .. "Recoup"] + if breakdown then + if output[recoupType .. "RecoveryRateMod"] ~= 1 then + breakdown[damageType .. recoupType .. "Recoup"] = { + s_format("%d%% ^8(base)", recoup), + s_format("* %.2f ^8(recovery rate modifier)", output[recoupType .. "RecoveryRateMod"]), + s_format("= %.1f%% over %d seconds", output[damageType .. recoupType .. "Recoup"], (modDB:Flag(nil, "4Second" .. recoupType .. "Recoup") or modDB:Flag(nil, "4SecondRecoup")) and 4 or 8) + } + else + breakdown[damageType .. recoupType .. "Recoup"] = { s_format("%d%% over %d seconds", output[damageType .. recoupType .. "Recoup"], (modDB:Flag(nil, "4Second" .. recoupType .. "Recoup") or modDB:Flag(nil, "4SecondRecoup")) and 4 or 8) } + end + end +end -- Performs all ingame and related defensive calculations function calcs.defence(env, actor) local modDB = actor.modDB @@ -1819,29 +1834,13 @@ function calcs.defence(env, actor) output.EnergyShieldRecharge = 0 end - -- recoup - local function calcRecoup(recoup, recoupType, damageType) - output[damageType..recoupType.."Recoup"] = recoup * output[recoupType.."RecoveryRateMod"] - output["anyRecoup"] = output["anyRecoup"] + output[damageType..recoupType.."Recoup"] - if breakdown then - if output[recoupType.."RecoveryRateMod"] ~= 1 then - breakdown[damageType..recoupType.."Recoup"] = { - s_format("%d%% ^8(base)", recoup), - s_format("* %.2f ^8(recovery rate modifier)", output[recoupType.."RecoveryRateMod"]), - s_format("= %.1f%% over %d seconds", output[damageType..recoupType.."Recoup"], (modDB:Flag(nil, "4Second"..recoupType.."Recoup") or modDB:Flag(nil, "4SecondRecoup")) and 4 or 8) - } - else - breakdown[damageType..recoupType.."Recoup"] = { s_format("%d%% over %d seconds", output[damageType..recoupType.."Recoup"], (modDB:Flag(nil, "4Second"..recoupType.."Recoup") or modDB:Flag(nil, "4SecondRecoup")) and 4 or 8) } - end - end - end do -- base Life/Mana/Energy Shield Recoup calcs output["anyRecoup"] = 0 local recoupTypeList = {"Life", "Mana", "EnergyShield"} for _, recoupType in ipairs(recoupTypeList) do local recoup = modDB:Sum("BASE", nil, recoupType.."Recoup") - calcRecoup(recoup, recoupType, "") + calcRecoup(output, breakdown, modDB, recoup, recoupType, "") if modDB:Flag(nil, "Add"..recoupType.."RecoupToEnergyShieldRecoup") then -- Sacrosanctum local mod = modDB:Tabulate("FLAG", nil, "Add"..recoupType.."RecoupToEnergyShieldRecoup")[1].mod @@ -1852,7 +1851,7 @@ function calcs.defence(env, actor) for _, recoupType in ipairs(recoupTypeList) do for _, damageType in ipairs(dmgTypeList) do local recoup = modDB:Sum("BASE", nil, damageType..recoupType.."Recoup") - calcRecoup(recoup, recoupType, damageType) + calcRecoup(output, breakdown, modDB, recoup, recoupType, damageType) if modDB:Flag(nil, "Add"..recoupType.."RecoupToEnergyShieldRecoup") then -- Sacrosanctum local mod = modDB:Tabulate("FLAG", nil, "Add"..recoupType.."RecoupToEnergyShieldRecoup")[1].mod @@ -2036,6 +2035,174 @@ function calcs.defence(env, actor) end end +-- function that iteratively reduces pools until life hits 0 to determine the number of hits it would take with given damage to die +local function numberOfHitsToDie(output, actor, DamageIn) + local numHits = 0 + DamageIn["cycles"] = DamageIn["cycles"] or 1 + DamageIn["iterations"] = DamageIn["iterations"] or 0 + + -- Check damage in isn't 0 + for _, damageType in ipairs(dmgTypeList) do + numHits = numHits + DamageIn[damageType] + end + if numHits == 0 then + return m_huge + else + numHits = 0 + end + + local ward = output.Ward or 0 + -- Don't apply Runic Ward when batching hits, as it only protects the first hit. + if DamageIn["cycles"] > 1 then + ward = 0 + end + local aegis = {} + aegis["shared"] = output["sharedAegis"] or 0 + aegis["sharedElemental"] = output["sharedElementalAegis"] or 0 + local guard = {} + guard["shared"] = output.sharedGuardAbsorb or 0 + for _, damageType in ipairs(dmgTypeList) do + aegis[damageType] = output[damageType .. "Aegis"] or 0 + guard[damageType] = output[damageType .. "GuardAbsorb"] or 0 + end + local alliesTakenBeforeYou = {} + if output.FrostShieldLife then + alliesTakenBeforeYou["frostShield"] = { remaining = output.FrostShieldLife, percent = output.FrostShieldDamageMitigation / 100 } + end + if output.TotalSpectreLife then + alliesTakenBeforeYou["spectres"] = { remaining = output.TotalSpectreLife, percent = output.SpectreAllyDamageMitigation / 100 } + end + if output.TotalTotemLife then + alliesTakenBeforeYou["totems"] = { remaining = output.TotalTotemLife, percent = output.TotemAllyDamageMitigation / 100 } + end + if output.TotalVaalRejuvenationTotemLife then + alliesTakenBeforeYou["vaalRejuvenationTotems"] = { remaining = output.TotalVaalRejuvenationTotemLife, percent = output.VaalRejuvenationTotemAllyDamageMitigation / 100 } + end + if output.TotalRadianceSentinelLife then + alliesTakenBeforeYou["radianceSentinel"] = { remaining = output.TotalRadianceSentinelLife, percent = output.RadianceSentinelAllyDamageMitigation / 100 } + end + if output.TotalCompanionLife then + alliesTakenBeforeYou["companion"] = { remaining = output.TotalCompanionLife, percent = output.CompanionAllyDamageMitigation / 100 } + end + if output.AlliedEnergyShield then + alliesTakenBeforeYou["soulLink"] = { remaining = output.AlliedEnergyShield, percent = output.SoulLinkMitigation / 100 } + end + + local poolTable = { + AlliesTakenBeforeYou = alliesTakenBeforeYou, + Aegis = aegis, + Guard = guard, + Ward = ward, + EnergyShield = output.EnergyShieldRecoveryCap, + Mana = output.ManaUnreserved or 0, + Life = output.LifeRecoverable or 0, + LifeLossLostOverTime = output.LifeLossLostOverTime or 0, + LifeBelowHalfLossLostOverTime = output.LifeBelowHalfLossLostOverTime or 0, + damageTakenThatCanBeRecouped = {} + } + + if DamageIn["cycles"] == 1 then + DamageIn["TrackRecoupable"] = DamageIn["TrackRecoupable"] or false + DamageIn["TrackLifeLossOverTime"] = DamageIn["TrackLifeLossOverTime"] or false + else + DamageIn["TrackRecoupable"] = false + DamageIn["TrackLifeLossOverTime"] = false + end + local VaalArcticArmourHitsLeft = output.VaalArcticArmourLife + if DamageIn["cycles"] > 1 then + VaalArcticArmourHitsLeft = 0 + end + + local iterationMultiplier = 1 + local damageTotal = 0 + local maxDamage = data.misc.ehpCalcMaxDamage + local maxIterations = data.misc.ehpCalcMaxIterationsToCalc + while poolTable.Life > 0 and DamageIn["iterations"] < maxIterations do + DamageIn["iterations"] = DamageIn["iterations"] + 1 + local Damage = {} + damageTotal = 0 + local VaalArcticArmourMultiplier = VaalArcticArmourHitsLeft > 0 and ((1 - output["VaalArcticArmourMitigation"] * m_min(VaalArcticArmourHitsLeft / iterationMultiplier, 1))) or 1 + VaalArcticArmourHitsLeft = VaalArcticArmourHitsLeft - iterationMultiplier + for _, damageType in ipairs(dmgTypeList) do + local damage = DamageIn[damageType] or 0 + Damage[damageType] = damage > 0 and damage * iterationMultiplier * VaalArcticArmourMultiplier or nil + damageTotal = damageTotal + damage + end + if DamageIn.GainWhenHit and (iterationMultiplier > 1 or DamageIn["cycles"] > 1) then + local gainMult = iterationMultiplier * DamageIn["cycles"] + poolTable.Life = m_min(poolTable.Life + DamageIn.LifeWhenHit * (gainMult - 1), gainMult * (output.LifeRecoverable or 0)) + poolTable.Mana = m_min(poolTable.Mana + DamageIn.ManaWhenHit * (gainMult - 1), gainMult * (output.ManaUnreserved or 0)) + poolTable.EnergyShield = m_min(poolTable.EnergyShield + DamageIn.EnergyShieldWhenHit * (gainMult - 1), gainMult * output.EnergyShieldRecoveryCap) + poolTable.Ward = m_min(poolTable.Ward + DamageIn.WardWhenHit * (gainMult - 1), gainMult * (output.Ward or 0)) + end + poolTable = calcs.reducePoolsByDamage(poolTable, Damage, actor) + + -- If still living and the amount of damage exceeds maximum threshold we survived infinite number of hits. + if poolTable.Life > 0 and damageTotal >= maxDamage then + return m_huge + end + if DamageIn.GainWhenHit and poolTable.Life > 0 then + poolTable.Life = m_min(poolTable.Life + DamageIn.LifeWhenHit, output.LifeRecoverable or 0) + poolTable.Mana = m_min(poolTable.Mana + DamageIn.ManaWhenHit, output.ManaUnreserved or 0) + poolTable.EnergyShield = m_min(poolTable.EnergyShield + DamageIn.EnergyShieldWhenHit, output.EnergyShieldRecoveryCap) + poolTable.Ward = m_min(poolTable.Ward + (DamageIn.WardWhenHit or 0), output.Ward or 0) + end + iterationMultiplier = 1 + -- to speed it up, run recursively but accelerated + -- MoM/life-loss-prevention mechanics can collapse too many hits into one + -- resulting in eHP jumps so we slow the acceleration. + local speedUp = DamageIn["LimitEHPSpeedup"] and 4 or data.misc.ehpCalcSpeedUp + DamageIn["cyclesRan"] = DamageIn["cyclesRan"] or false + if not DamageIn["cyclesRan"] and poolTable.Life > 0 and DamageIn["iterations"] < maxIterations then + Damage = {} + for _, damageType in ipairs(dmgTypeList) do + Damage[damageType] = DamageIn[damageType] * speedUp + end + Damage["LimitEHPSpeedup"] = DamageIn["LimitEHPSpeedup"] + if DamageIn.GainWhenHit then + Damage.GainWhenHit = true + Damage.LifeWhenHit = DamageIn.LifeWhenHit + Damage.ManaWhenHit = DamageIn.ManaWhenHit + Damage.EnergyShieldWhenHit = DamageIn.EnergyShieldWhenHit + Damage.WardWhenHit = DamageIn.WardWhenHit + end + Damage["cycles"] = DamageIn["cycles"] * speedUp + Damage["iterations"] = DamageIn["iterations"] + iterationMultiplier = m_max((numberOfHitsToDie(output, actor, Damage) - 1) * speedUp - 1, 1) + if iterationMultiplier == m_huge then -- avoid unnecessary calculations if we know we survive infinite hits. + return m_huge + end + DamageIn["iterations"] = Damage["iterations"] + DamageIn["cyclesRan"] = true + end + numHits = numHits + iterationMultiplier + end + if DamageIn.TrackRecoupable then + for damageType, recoupable in pairs(poolTable.damageTakenThatCanBeRecouped) do + output[damageType .. "RecoupableDamageTaken"] = output[damageType .. "RecoupableDamageTaken"] + recoupable + end + end + if DamageIn["TrackLifeLossOverTime"] then + output.LifeLossLostOverTime = output.LifeLossLostOverTime + poolTable.LifeLossLostOverTime + output.LifeBelowHalfLossLostOverTime = output.LifeBelowHalfLossLostOverTime + poolTable.LifeBelowHalfLossLostOverTime + end + + if poolTable.Life == 0 and DamageIn["cycles"] == 1 then -- Don't count overkill damage and only on final pass as to not break speedup. + numHits = numHits - poolTable.OverkillDamage / damageTotal + end + -- Recalculate total hit damage + damageTotal = 0 + for _, damageType in ipairs(dmgTypeList) do + damageTotal = damageTotal + DamageIn[damageType] * numHits + end + if poolTable.Life >= 0 and damageTotal >= maxDamage then -- If still living and the amount of damage exceeds maximum threshold we survived infinite number of hits. + return m_huge + end + if numHits ~= numHits then + return 0 + end + return m_max(numHits, 0) +end -- Performs all extra defensive calculations ( eg EHP, maxHit ) function calcs.buildDefenceEstimations(env, actor) local modDB = actor.modDB @@ -3053,175 +3220,6 @@ function calcs.buildDefenceEstimations(env, actor) end end - -- helper function that iteratively reduces pools until life hits 0 to determine the number of hits it would take with given damage to die - local function numberOfHitsToDie(DamageIn) - local numHits = 0 - DamageIn["cycles"] = DamageIn["cycles"] or 1 - DamageIn["iterations"] = DamageIn["iterations"] or 0 - - -- check damage in isn't 0 and that ward doesn't mitigate all damage - for _, damageType in ipairs(dmgTypeList) do - numHits = numHits + DamageIn[damageType] - end - if numHits == 0 then - return m_huge - else - numHits = 0 - end - - local ward = output.Ward or 0 - -- Don't apply Runic Ward when batching hits, as it only protects the first hit. - if DamageIn["cycles"] > 1 then - ward = 0 - end - local aegis = { } - aegis["shared"] = output["sharedAegis"] or 0 - aegis["sharedElemental"] = output["sharedElementalAegis"] or 0 - local guard = { } - guard["shared"] = output.sharedGuardAbsorb or 0 - for _, damageType in ipairs(dmgTypeList) do - aegis[damageType] = output[damageType.."Aegis"] or 0 - guard[damageType] = output[damageType.."GuardAbsorb"] or 0 - end - local alliesTakenBeforeYou = {} - if output.FrostShieldLife then - alliesTakenBeforeYou["frostShield"] = { remaining = output.FrostShieldLife, percent = output.FrostShieldDamageMitigation / 100 } - end - if output.TotalSpectreLife then - alliesTakenBeforeYou["spectres"] = { remaining = output.TotalSpectreLife, percent = output.SpectreAllyDamageMitigation / 100 } - end - if output.TotalTotemLife then - alliesTakenBeforeYou["totems"] = { remaining = output.TotalTotemLife, percent = output.TotemAllyDamageMitigation / 100 } - end - if output.TotalVaalRejuvenationTotemLife then - alliesTakenBeforeYou["vaalRejuvenationTotems"] = { remaining = output.TotalVaalRejuvenationTotemLife, percent = output.VaalRejuvenationTotemAllyDamageMitigation / 100 } - end - if output.TotalRadianceSentinelLife then - alliesTakenBeforeYou["radianceSentinel"] = { remaining = output.TotalRadianceSentinelLife, percent = output.RadianceSentinelAllyDamageMitigation / 100 } - end - if output.TotalCompanionLife then - alliesTakenBeforeYou["companion"] = { remaining = output.TotalCompanionLife, percent = output.CompanionAllyDamageMitigation / 100 } - end - if output.AlliedEnergyShield then - alliesTakenBeforeYou["soulLink"] = { remaining = output.AlliedEnergyShield, percent = output.SoulLinkMitigation / 100 } - end - - local poolTable = { - AlliesTakenBeforeYou = alliesTakenBeforeYou, - Aegis = aegis, - Guard = guard, - Ward = ward, - EnergyShield = output.EnergyShieldRecoveryCap, - Mana = output.ManaUnreserved or 0, - Life = output.LifeRecoverable or 0, - LifeLossLostOverTime = output.LifeLossLostOverTime or 0, - LifeBelowHalfLossLostOverTime = output.LifeBelowHalfLossLostOverTime or 0, - damageTakenThatCanBeRecouped = { } - } - - if DamageIn["cycles"] == 1 then - DamageIn["TrackRecoupable"] = DamageIn["TrackRecoupable"] or false - DamageIn["TrackLifeLossOverTime"] = DamageIn["TrackLifeLossOverTime"] or false - else - DamageIn["TrackRecoupable"] = false - DamageIn["TrackLifeLossOverTime"] = false - end - local VaalArcticArmourHitsLeft = output.VaalArcticArmourLife - if DamageIn["cycles"] > 1 then - VaalArcticArmourHitsLeft = 0 - end - - local iterationMultiplier = 1 - local damageTotal = 0 - local maxDamage = data.misc.ehpCalcMaxDamage - local maxIterations = data.misc.ehpCalcMaxIterationsToCalc - while poolTable.Life > 0 and DamageIn["iterations"] < maxIterations do - DamageIn["iterations"] = DamageIn["iterations"] + 1 - local Damage = { } - damageTotal = 0 - local VaalArcticArmourMultiplier = VaalArcticArmourHitsLeft > 0 and (( 1 - output["VaalArcticArmourMitigation"] * m_min(VaalArcticArmourHitsLeft / iterationMultiplier, 1))) or 1 - VaalArcticArmourHitsLeft = VaalArcticArmourHitsLeft - iterationMultiplier - for _, damageType in ipairs(dmgTypeList) do - local damage = DamageIn[damageType] or 0 - Damage[damageType] = damage > 0 and damage * iterationMultiplier * VaalArcticArmourMultiplier or nil - damageTotal = damageTotal + damage - end - if DamageIn.GainWhenHit and (iterationMultiplier > 1 or DamageIn["cycles"] > 1) then - local gainMult = iterationMultiplier * DamageIn["cycles"] - poolTable.Life = m_min(poolTable.Life + DamageIn.LifeWhenHit * (gainMult - 1), gainMult * (output.LifeRecoverable or 0)) - poolTable.Mana = m_min(poolTable.Mana + DamageIn.ManaWhenHit * (gainMult - 1), gainMult * (output.ManaUnreserved or 0)) - poolTable.EnergyShield = m_min(poolTable.EnergyShield + DamageIn.EnergyShieldWhenHit * (gainMult - 1), gainMult * output.EnergyShieldRecoveryCap) - poolTable.Ward = m_min(poolTable.Ward + DamageIn.WardWhenHit * (gainMult - 1), gainMult * (output.Ward or 0)) - end - poolTable = calcs.reducePoolsByDamage(poolTable, Damage, actor) - - -- If still living and the amount of damage exceeds maximum threshold we survived infinite number of hits. - if poolTable.Life > 0 and damageTotal >= maxDamage then - return m_huge - end - if DamageIn.GainWhenHit and poolTable.Life > 0 then - poolTable.Life = m_min(poolTable.Life + DamageIn.LifeWhenHit, output.LifeRecoverable or 0) - poolTable.Mana = m_min(poolTable.Mana + DamageIn.ManaWhenHit, output.ManaUnreserved or 0) - poolTable.EnergyShield = m_min(poolTable.EnergyShield + DamageIn.EnergyShieldWhenHit, output.EnergyShieldRecoveryCap) - poolTable.Ward = m_min(poolTable.Ward + (DamageIn.WardWhenHit or 0), output.Ward or 0) - end - iterationMultiplier = 1 - -- to speed it up, run recursively but accelerated - -- MoM/life-loss-prevention mechanics can collapse too many hits into one - -- resulting in eHP jumps so we slow the acceleration. - local speedUp = DamageIn["LimitEHPSpeedup"] and 4 or data.misc.ehpCalcSpeedUp - DamageIn["cyclesRan"] = DamageIn["cyclesRan"] or false - if not DamageIn["cyclesRan"] and poolTable.Life > 0 and DamageIn["iterations"] < maxIterations then - Damage = { } - for _, damageType in ipairs(dmgTypeList) do - Damage[damageType] = DamageIn[damageType] * speedUp - end - Damage["LimitEHPSpeedup"] = DamageIn["LimitEHPSpeedup"] - if DamageIn.GainWhenHit then - Damage.GainWhenHit = true - Damage.LifeWhenHit = DamageIn.LifeWhenHit - Damage.ManaWhenHit = DamageIn.ManaWhenHit - Damage.EnergyShieldWhenHit = DamageIn.EnergyShieldWhenHit - Damage.WardWhenHit = DamageIn.WardWhenHit - end - Damage["cycles"] = DamageIn["cycles"] * speedUp - Damage["iterations"] = DamageIn["iterations"] - iterationMultiplier = m_max((numberOfHitsToDie(Damage) - 1) * speedUp - 1, 1) - if iterationMultiplier == m_huge then -- avoid unnecessary calculations if we know we survive infinite hits. - return m_huge - end - DamageIn["iterations"] = Damage["iterations"] - DamageIn["cyclesRan"] = true - end - numHits = numHits + iterationMultiplier - end - if DamageIn.TrackRecoupable then - for damageType, recoupable in pairs(poolTable.damageTakenThatCanBeRecouped) do - output[damageType.."RecoupableDamageTaken"] = output[damageType.."RecoupableDamageTaken"] + recoupable - end - end - if DamageIn["TrackLifeLossOverTime"] then - output.LifeLossLostOverTime = output.LifeLossLostOverTime + poolTable.LifeLossLostOverTime - output.LifeBelowHalfLossLostOverTime = output.LifeBelowHalfLossLostOverTime + poolTable.LifeBelowHalfLossLostOverTime - end - - if poolTable.Life == 0 and DamageIn["cycles"] == 1 then -- Don't count overkill damage and only on final pass as to not break speedup. - numHits = numHits - poolTable.OverkillDamage / damageTotal - end - -- Recalculate total hit damage - damageTotal = 0 - for _, damageType in ipairs(dmgTypeList) do - damageTotal = damageTotal + DamageIn[damageType] * numHits - end - if poolTable.Life >= 0 and damageTotal >= maxDamage then -- If still living and the amount of damage exceeds maximum threshold we survived infinite number of hits. - return m_huge - end - if numHits ~= numHits then - return 0 - end - return m_max(numHits, 0) - end - if damageCategoryConfig ~= "DamageOverTime" then -- number of damaging hits needed to be taken to die do @@ -3230,7 +3228,7 @@ function calcs.buildDefenceEstimations(env, actor) DamageIn[damageType] = output[damageType.."TakenHit"] end DamageIn["LimitEHPSpeedup"] = output["preventedLifeLossTotal"] > 0 - output["NumberOfDamagingHits"] = numberOfHitsToDie(DamageIn) + output["NumberOfDamagingHits"] = numberOfHitsToDie(output, actor, DamageIn) end @@ -3326,7 +3324,7 @@ function calcs.buildDefenceEstimations(env, actor) DamageIn["LimitEHPSpeedup"] = DamageIn["TrackRecoupable"] or DamageIn["TrackLifeLossOverTime"] or DamageIn.GainWhenHit averageAvoidChance = averageAvoidChance / 5 output["ConfiguredDamageChance"] = 100 * (blockEffect * suppressionEffect * effectiveDeflectMulti * (1 - averageAvoidChance / 100)) - output["NumberOfMitigatedDamagingHits"] = (output["ConfiguredDamageChance"] ~= 100 or DamageIn["TrackRecoupable"] or DamageIn["TrackLifeLossOverTime"] or DamageIn.GainWhenHit) and numberOfHitsToDie(DamageIn) or output["NumberOfDamagingHits"] + output["NumberOfMitigatedDamagingHits"] = (output["ConfiguredDamageChance"] ~= 100 or DamageIn["TrackRecoupable"] or DamageIn["TrackLifeLossOverTime"] or DamageIn.GainWhenHit) and numberOfHitsToDie(output, actor, DamageIn) or output["NumberOfDamagingHits"] if breakdown then breakdown["ConfiguredDamageChance"] = { s_format("%.2f ^8(chance for block to fail)", 1 - BlockChance) @@ -4375,4 +4373,4 @@ function calcs.buildDefenceEstimations(env, actor) --endregion end -return calcs \ No newline at end of file +return calcs diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index d2bd0e5337..960a5e16d4 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -67,6 +67,77 @@ local globalOutput = nil ---@type Breakdown? local globalBreakdown = nil +local function processDamageConversion(skillModList, skillCfg, fromType, skill) + local total = 0 + local totalConv = wipeTable(tempTable1) + + -- Calculate conversion for this damage type + for _, toType in ipairs(dmgTypeList) do + local conv + if skill then + conv = m_max(skillModList:Sum("BASE", skillCfg, + "SkillDamageConvertTo"..toType, + "Skill"..fromType.."DamageConvertTo"..toType), 0) + else + conv = m_max(skillModList:Sum("BASE", skillCfg, + "DamageConvertTo"..toType, + fromType.."DamageConvertTo"..toType, + isElemental[fromType] and "ElementalDamageConvertTo"..toType or nil, + fromType ~= "Chaos" and "NonChaosDamageConvertTo"..toType or nil), 0) + end + + totalConv[toType] = conv / 100 + total = total + conv + end + + -- Scale if over 100% + if total > 100 then + local factor = 100 / total + for type, val in pairs(totalConv) do + totalConv[type] = val * factor + end + total = 100 + end + + return totalConv, total +end + +local selfHitHandlers = { + ["Heartbound Loop"] = function(activeSkill, output, breakdown) + if activeSkill.activeEffect.grantedEffect.name == "Summon Skeletons" then + local dmgType, dmgVal + for _, value in ipairs(activeSkill.skillModList:List(nil, "HeartboundLoopSelfDamage")) do -- Combines dmg taken from both ring accounting for catalysts + dmgVal = (dmgVal or 0) + value.baseDamage + dmgType = string.gsub(" "..value.damageType, "%W%l", string.upper):sub(2) -- This assumes both rings deal the same damage type + end + if dmgType and dmgVal then + -- !!!! WARNING !!!! -- + -- applyDmgTakenConversion does NOT consider the "And protect me from Harm" yet + local dmgBreakdown, totalDmgTaken = calcs.applyDmgTakenConversion(activeSkill, output, breakdown, dmgType, dmgVal) + t_insert(dmgBreakdown, 1, s_format("Heartbound Loop base damage: %d", dmgVal)) + t_insert(dmgBreakdown, 2, s_format("")) + t_insert(dmgBreakdown, s_format("Total Heartbound Loop damage taken per cast/attack: %.2f * %d ^8(minions per cast)^7 = %.2f",totalDmgTaken, output.SummonedMinionsPerCast, totalDmgTaken * output.SummonedMinionsPerCast)) + return dmgBreakdown, totalDmgTaken * output.SummonedMinionsPerCast + end + end + end, + ["Trauma"] = function(activeSkill, output, breakdown) + local dmgType = "Physical" + local currentTraumaStacks = math.max(activeSkill.skillModList:Sum("BASE", nil, "Multiplier:TraumaStacks"), 1) + local damagePerTrauma = activeSkill.skillModList:Sum("BASE", nil, "TraumaSelfDamageTakenLife") + local dmgVal = activeSkill.baseSkillModList:Flag(nil, "HasTrauma") and damagePerTrauma * currentTraumaStacks + if dmgType and dmgVal then + -- !!!! WARNING !!!! -- + -- applyDmgTakenConversion does NOT consider the "And protect me from Harm" yet + local dmgBreakdown, totalDmgTaken = calcs.applyDmgTakenConversion(activeSkill, output, breakdown, dmgType, dmgVal) + t_insert(dmgBreakdown, 1, s_format("%d ^8(base %s damage)^7 * %.2f ^8(%s trauma)^7 = %.2f %s damage", damagePerTrauma, dmgType, currentTraumaStacks, activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:SustainableTraumaStacks") == currentTraumaStacks and "sustainable" or "current", dmgVal, dmgType)) + t_insert(dmgBreakdown, 2, s_format("")) + t_insert(dmgBreakdown, s_format("Total Trauma damage taken per cast/attack: %.2f ", totalDmgTaken)) + return dmgBreakdown, totalDmgTaken + end + end, +} + local function calcConvertedDamage(activeSkill, output, cfg, damageType) local skillModList = activeSkill.skillModList -- Calculate conversions @@ -392,6 +463,63 @@ function calcSkillDuration(skillModList, skillCfg, skillData, env, enemyDB) return duration end +local calcPenResist = function(resist, minPen, pen) + return resist > minPen and m_max(resist - pen, minPen) or resist +end + +local function calcHitResist(resist, minPen, pen, cannotElePenIgnore, ignoreNonNegativeEleRes) + if not cannotElePenIgnore and ignoreNonNegativeEleRes and resist >= 0 then + return 0 + end + return cannotElePenIgnore and resist or calcPenResist(resist, minPen, pen) +end + +-- Determine base leech value according to resource (using function to avoid repetition) +---@param resource string "Life" | "Mana" | "EnergyShield" +---@param dmgType string "Physical" | "Cold" | "Fire" | "Lightning" | "Chaos" +---@return number +local function getBaseLeech(resource, dmgType, skillModList, cfg, enemyDB) + local leech = 0 + if (not skillModList:Flag(cfg, "Condition:No" .. resource .. "LeechFrom" .. dmgType .. "Damage")) and not (isElemental[dmgType] and skillModList:Flag(cfg, "No" .. resource .. "LeechFromElementalDamage")) then + -- Check if converted physical leech (most PoE2 leech is physical only by default) + local convertModName, convertFlag + if isElemental[dmgType] and skillModList:Flag(cfg, resource .. "LeechBasedOnElementalDamage") then + convertFlag = resource .. "LeechBasedOnElementalDamage" + convertModName = "ElementalDamage" .. resource .. "Leech" + elseif skillModList:Flag(cfg, resource .. "LeechBasedOn" .. dmgType .. "Damage") then + convertFlag = resource .. "LeechBasedOn" .. dmgType .. "Damage" + convertModName = dmgType .. "Damage" .. resource .. "Leech" + end + if convertModName and convertFlag then + local tempCfg = copyTable(cfg, true) + tempCfg.overrideCond = { ["No" .. resource .. "LeechFromPhysicalDamage"] = false } -- Need to force Condition to `false`, to calculate original phys leech values + local physLeechMods = skillModList:Tabulate("BASE", tempCfg, "PhysicalDamage" .. resource .. "Leech") + for _, entry in ipairs(physLeechMods) do + -- Add new leech mods for that damage type with the same conditions, source, etc. + local newMod = copyTable(entry.mod) + newMod.name = convertModName + -- Tags that specifically disable Physical Damage leech need to be removed + local hasNoPhysLeech, tagIndex = modLib.hasTag(newMod, { type = "Condition", var = "No" .. resource .. "LeechFromPhysicalDamage", neg = true }) + if hasNoPhysLeech then + t_remove(newMod, tagIndex) + end + if not skillModList:ReplaceModInternal(newMod) then -- using `ReplaceModInternal` instead of `ReplaceMod`, so I don't have to unpack the mod first + skillModList:AddMod(newMod) + end + end + end + leech = skillModList:Sum("BASE", cfg, "Damage" .. resource .. "Leech", dmgType .. "Damage" .. resource .. "Leech", isElemental[dmgType] and "ElementalDamage" .. resource .. "Leech" or nil) + enemyDB:Sum("BASE", cfg, "SelfDamage" .. resource .. "Leech") / 100 + elseif skillModList:Flag(cfg, "Condition:No" .. resource .. "LeechFrom" .. dmgType .. "Damage") then + -- dmgType leech should not apply, but still needs to exist for possible conversion so adding additional condition tag instead + local noLeechFlagTag = { type = "Condition", var = "No" .. resource .. "LeechFrom" .. dmgType .. "Damage", neg = true } + for _, entry in ipairs(skillModList:Tabulate("BASE", cfg, dmgType .. "Damage" .. resource .. "Leech")) do + if not modLib.hasTag(entry.mod, noLeechFlagTag) then + t_insert(entry.mod, noLeechFlagTag) + end + end + end + return leech and leech or 0 +end -- Performs all offensive calculations ---@param env Env ---@param actor Actor @@ -2313,72 +2441,9 @@ function calcs.offence(env, actor, activeSkill) end end - -- Calculate conversion - local function processDamageConversion(fromType, skill) - local total = 0 - local totalConv = wipeTable(tempTable1) - - -- Calculate conversion for this damage type - for _, toType in ipairs(dmgTypeList) do - local conv - if skill then - conv = m_max(skillModList:Sum("BASE", skillCfg, - "SkillDamageConvertTo"..toType, - "Skill"..fromType.."DamageConvertTo"..toType), 0) - else - conv = m_max(skillModList:Sum("BASE", skillCfg, - "DamageConvertTo"..toType, - fromType.."DamageConvertTo"..toType, - isElemental[fromType] and "ElementalDamageConvertTo"..toType or nil, - fromType ~= "Chaos" and "NonChaosDamageConvertTo"..toType or nil), 0) - end - - totalConv[toType] = conv / 100 - total = total + conv - end - - -- Scale if over 100% - if total > 100 then - local factor = 100 / total - for type, val in pairs(totalConv) do - totalConv[type] = val * factor - end - total = 100 - end - - return totalConv, total - end - - local function buildGainTable() - for _, damageType in ipairs(dmgTypeList) do - activeSkill.gainTable[damageType] = {} - for _, toType in ipairs(dmgTypeList) do - local globalGain = m_max(skillModList:Sum("BASE", skillCfg, - "DamageAs"..toType, - "DamageGainAs"..toType, - damageType.."DamageAs"..toType, - damageType.."DamageGainAs"..toType, - isElemental[damageType] and "ElementalDamageAs"..toType or nil, - isElemental[damageType] and "ElementalDamageGainAs"..toType or nil, - damageType ~= "Chaos" and "NonChaosDamageAs"..toType or nil, - damageType ~= "Chaos" and "NonChaosDamageGainAs"..toType or nil), 0) - local skillGain = m_max(skillModList:Sum("BASE", skillCfg, - "SkillDamageGainAs"..toType, - "Skill"..damageType.."DamageGainAs"..toType, - isElemental[damageType] and "SkillElementalDamageGainAs"..toType or nil, - damageType ~= "Chaos" and "SkillNonChaosDamageGainAs"..toType or nil), 0) - if skillModList:Flag(skillCfg, "DamageGainIsOnlyCold") and toType ~= "Cold" then - activeSkill.gainTable[damageType]["Cold"] = (activeSkill.gainTable[damageType]["Cold"] or 0) + (globalGain + skillGain) / 100 - else - activeSkill.gainTable[damageType][toType] = (activeSkill.gainTable[damageType][toType] or 0) + (globalGain + skillGain) / 100 - end - end - end - end - -- First step: Process skill conversion for _, damageType in ipairs(dmgTypeList) do - local skillConv, skillTotal = processDamageConversion(damageType, true) + local skillConv, skillTotal = processDamageConversion(skillModList, skillCfg, damageType, true) for toType, amount in pairs(skillConv) do activeSkill.conversionTable[damageType][toType] = amount end @@ -2391,7 +2456,7 @@ function calcs.offence(env, actor, activeSkill) -- Handle global conversion of unconverted damage first if activeSkill.conversionTable[damageType].mult > 0 then - local globalConv, globalTotal = processDamageConversion(damageType) + local globalConv, globalTotal = processDamageConversion(skillModList, skillCfg, damageType) if globalTotal > 0 then local unconvertedMult = activeSkill.conversionTable[damageType].mult tempConversions[damageType] = { @@ -2407,7 +2472,7 @@ function calcs.offence(env, actor, activeSkill) -- Process global conversion on skill-converted damage for toType, amount in pairs(activeSkill.conversionTable[damageType]) do if amount > 0 and toType ~= "mult" then - local globalConv, globalTotal = processDamageConversion(toType) + local globalConv, globalTotal = processDamageConversion(skillModList, skillCfg, toType) if globalTotal > 0 then tempConversions[toType] = { base = amount * (1 - globalTotal / 100), @@ -3272,6 +3337,100 @@ function calcs.offence(env, actor, activeSkill) end end + -- Calculate leech + local function getLeechInstances(amount, total, hitRate) + if total == 0 then + return 0, 0 + end + local duration = amount / total / data.misc.LeechRateBase + return duration, duration * hitRate + end + -- dynamic way of calculating the Ancestral Boost from a single source without duplicating the code + -- uptimeOverride: Ancestral Empowerment + -- combinedCalcs: ignore INC AoE as we will run that in calcCombinedAncestralBoost + local function calcAncestralBoost(skillName, moreDmg, uptimeOverride, combinedCalcs) + globalOutput.CreateWarcryOffensiveCalcSection = true -- labels for the CalcSection + local skillNameVar = skillName:gsub(" ", "") -- Fist Of War -> FistOfWar + local skillNameLabel = skillName:lower() + + globalOutput[skillNameVar .. "DamageMultiplier"] = moreDmg or 1 + globalOutput[skillNameVar .. "UptimeRatio"] = uptimeOverride or m_min((1 / globalOutput.Speed) / globalOutput[skillNameVar .. "Cooldown"], 1) * 100 + if globalBreakdown then + globalBreakdown[skillNameVar .. "UptimeRatio"] = { + s_format("min( (1 / %.2f) ^8(second per attack)", globalOutput.Speed), + s_format("/ %.2f, 1) ^8(" .. skillNameLabel .. " cooldown)", uptimeOverride and (1 / globalOutput.Speed / (uptimeOverride / 100)) or globalOutput[skillNameVar .. "Cooldown"]), + s_format("= %d%%", globalOutput[skillNameVar .. "UptimeRatio"]), + } + end + globalOutput["Avg" .. skillNameVar .. "Damage"] = globalOutput[skillNameVar .. "DamageMultiplier"] + globalOutput["Avg" .. skillNameVar .. "DamageEffect"] = 1 + globalOutput["Avg" .. skillNameVar .. "Damage"] * (globalOutput[skillNameVar .. "UptimeRatio"] / 100) + if globalBreakdown then + globalBreakdown["Avg" .. skillNameVar .. "DamageEffect"] = { + s_format("1 + (%.2f ^8(" .. skillNameLabel .. " damage multiplier)", globalOutput[skillNameVar .. "DamageMultiplier"]), + s_format("x %.2f) ^8(" .. skillNameLabel .. " uptime ratio)", globalOutput[skillNameVar .. "UptimeRatio"] / 100), + s_format("= %.2f", globalOutput["Avg" .. skillNameVar .. "DamageEffect"]), + } + end + globalOutput["Max" .. skillNameVar .. "DamageEffect"] = 1 + globalOutput[skillNameVar .. "DamageMultiplier"] + if activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then + output[skillNameVar .. "DamageEffect"] = globalOutput["Max" .. skillNameVar .. "DamageEffect"] + else + output[skillNameVar .. "DamageEffect"] = globalOutput["Avg" .. skillNameVar .. "DamageEffect"] + end + calcAreaOfEffect(skillModList, skillCfg, skillData, skillFlags, globalOutput, globalBreakdown) + globalOutput.TheoreticalOffensiveWarcryEffect = globalOutput.TheoreticalOffensiveWarcryEffect * globalOutput["Avg" .. skillNameVar .. "DamageEffect"] + globalOutput.TheoreticalMaxOffensiveWarcryEffect = globalOutput.TheoreticalMaxOffensiveWarcryEffect * globalOutput["Max" .. skillNameVar .. "DamageEffect"] + end + + -- combine Ancestral Empowerment with other sources of Slam Ancestral Boost, namely Fist of War, when both active + local function calcCombinedAncestralBoost(skillName, moreDmg, uptimeOverride, additionalSkillName) + globalOutput.CreateWarcryOffensiveCalcSection = true -- labels for the CalcSection + local skillNameVar = skillName:gsub(" ", "") -- Fist Of War -> FistOfWar + local skillNameLabel = skillName:lower() + + globalOutput[skillNameVar .. "DamageMultiplier"] = moreDmg or 1 + -- for CalcSections, set the AncestralEmpowerment damage for mod breakdown + globalOutput[skillNameVar .. "CombinedDamageMultiplier"] = globalOutput[skillNameVar .. "DamageMultiplier"] + skillNameVar = skillNameVar .. "Combined" + local additionalSkillNameVar = additionalSkillName:gsub(" ", "") + local additionalSkillNameLabel = additionalSkillName:lower() + + -- a lot of these are doubled up because it would be very long lines otherwise and hopefully this helps legibility + globalOutput[skillNameVar .. "UptimeRatio"] = uptimeOverride or m_min((1 / globalOutput.Speed) / globalOutput[skillNameVar .. "Cooldown"], 1) * 100 + globalOutput[skillNameVar .. "UptimeRatio"] = m_min(globalOutput[skillNameVar .. "UptimeRatio"] + (globalOutput[additionalSkillNameVar .. "UptimeRatio"] or 0), 100) + if globalBreakdown then + globalBreakdown[skillNameVar .. "UptimeRatio"] = { + s_format("min( (1 / %.2f) ^8(second per attack)", globalOutput.Speed), + s_format("/ %.2f, 1) ^8(" .. skillNameLabel .. " cooldown)", uptimeOverride and (1 / globalOutput.Speed / (uptimeOverride / 100)) or globalOutput[skillNameVar .. "Cooldown"]), + "+", + s_format("min( (1 / %.2f) ^8(second per attack)", globalOutput.Speed), + s_format("/ %.2f, 1) ^8(" .. additionalSkillNameLabel .. " cooldown)", globalOutput[additionalSkillNameVar .. "Cooldown"]), + "capped at 100%", + s_format("= %d%%", globalOutput[skillNameVar .. "UptimeRatio"]), + } + end + globalOutput["Avg" .. skillNameVar .. "Damage"] = globalOutput[skillNameVar .. "DamageMultiplier"] + globalOutput["Avg" .. skillNameVar .. "DamageEffect"] = 1 + globalOutput["Avg" .. skillNameVar .. "Damage"] * (globalOutput[skillNameVar .. "UptimeRatio"] / 100) + if globalBreakdown then + globalBreakdown["Avg" .. skillNameVar .. "DamageEffect"] = { + s_format("1 + (%.2f x %.2f) ^8(combined ancestral boost damage multiplier x uptime ratio)", globalOutput[skillNameVar .. "DamageMultiplier"], globalOutput[skillNameVar .. "UptimeRatio"] / 100), + s_format("= %.2f", globalOutput["Avg" .. skillNameVar .. "DamageEffect"]), + } + end + globalOutput["Max" .. skillNameVar .. "DamageEffect"] = 1 + globalOutput[skillNameVar .. "DamageMultiplier"] + if activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then + output[skillNameVar .. "DamageEffect"] = globalOutput["Max" .. skillNameVar .. "DamageEffect"] + else + output[skillNameVar .. "DamageEffect"] = globalOutput["Avg" .. skillNameVar .. "DamageEffect"] + end + calcAreaOfEffect(skillModList, skillCfg, skillData, skillFlags, globalOutput, globalBreakdown) + globalOutput.TheoreticalOffensiveWarcryEffect = globalOutput.TheoreticalOffensiveWarcryEffect * globalOutput["Avg" .. skillNameVar .. "DamageEffect"] + globalOutput.TheoreticalMaxOffensiveWarcryEffect = globalOutput.TheoreticalMaxOffensiveWarcryEffect * globalOutput["Max" .. skillNameVar .. "DamageEffect"] + end + -- Check if player is supposed to ignore a damage type, or if it's ignored on enemy side + local useThisResist = function(cfg, damageType) + return not skillModList:Flag(cfg, "Ignore"..damageType.."Resistance", isElemental[damageType] and "IgnoreElementalResistances" or nil) and not enemyDB:Flag(nil, "SelfIgnore"..damageType.."Resistance") + end --Calculate damage (exerts, crits, ruthless, DPS, etc) for _, pass in ipairs(passList) do globalOutput, globalBreakdown = output, breakdown @@ -3529,110 +3688,25 @@ function calcs.offence(env, actor, activeSkill) local ruthlessBlowStunEffect = (ruthlessBlowChance / 100) * ruthlessBlowStunMultiplier skillModList:NewMod("EnemyHeavyStunBuildup", "MORE", ruthlessBlowStunEffect * 100, "Ruthless Blows") - local ancestrallyBoostedIncDamageMulti = modDB:Sum("INC", cfg, "AncestralBoostDamage") / 100 - local ancestrallyBoostedIncArea = skillModList:Sum("INC", cfg, "AncestralBoostAreaOfEffect") -- Condition:AncestrallyBoosted * AncestralBoostEffect (e.g. Fist of War III) local ancestrallyBoostedMoreDamageMulti = skillModList:Sum("BASE", cfg, "AncestralBoostMoreDamage") / 100 - -- dynamic way of calculating the Ancestral Boost from a single source without duplicating the code - -- uptimeOverride: Ancestral Empowerment - -- combinedCalcs: ignore INC AoE as we will run that in calcCombinedAncestralBoost - local function calcAncestralBoost(skillName, uptimeOverride, combinedCalcs) - globalOutput.CreateWarcryOffensiveCalcSection = true -- labels for the CalcSection - local skillNameVar = skillName:gsub(" ", "") -- Fist Of War -> FistOfWar - local skillNameLabel = skillName:lower() - - globalOutput[skillNameVar.."DamageMultiplier"] = ancestrallyBoostedMoreDamageMulti - globalOutput[skillNameVar.."UptimeRatio"] = uptimeOverride or m_min( (1 / globalOutput.Speed) / globalOutput[skillNameVar.."Cooldown"], 1) * 100 - if globalBreakdown then - globalBreakdown[skillNameVar.."UptimeRatio"] = { - s_format("min( (1 / %.2f) ^8(second per attack)", globalOutput.Speed), - s_format("/ %.2f, 1) ^8("..skillNameLabel.." cooldown)", uptimeOverride and (1 / globalOutput.Speed / (uptimeOverride / 100)) or globalOutput[skillNameVar.."Cooldown"]), - s_format("= %d%%", globalOutput[skillNameVar.."UptimeRatio"]), - } - end - globalOutput["Avg"..skillNameVar.."Damage"] = globalOutput[skillNameVar.."DamageMultiplier"] - globalOutput["Avg"..skillNameVar.."DamageEffect"] = 1 + globalOutput["Avg"..skillNameVar.."Damage"] * (globalOutput[skillNameVar.."UptimeRatio"] / 100) - if globalBreakdown then - globalBreakdown["Avg"..skillNameVar.."DamageEffect"] = { - s_format("1 + (%.2f ^8("..skillNameLabel.." damage multiplier)", globalOutput[skillNameVar.."DamageMultiplier"]), - s_format("x %.2f) ^8("..skillNameLabel.." uptime ratio)", globalOutput[skillNameVar.."UptimeRatio"] / 100), - s_format("= %.2f", globalOutput["Avg"..skillNameVar.."DamageEffect"]), - } - end - globalOutput["Max"..skillNameVar.."DamageEffect"] = 1 + globalOutput[skillNameVar.."DamageMultiplier"] - if activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then - output[skillNameVar.."DamageEffect"] = globalOutput["Max"..skillNameVar.."DamageEffect"] - else - output[skillNameVar.."DamageEffect"] = globalOutput["Avg"..skillNameVar.."DamageEffect"] - end - calcAreaOfEffect(skillModList, skillCfg, skillData, skillFlags, globalOutput, globalBreakdown) - globalOutput.TheoreticalOffensiveWarcryEffect = globalOutput.TheoreticalOffensiveWarcryEffect * globalOutput["Avg"..skillNameVar.."DamageEffect"] - globalOutput.TheoreticalMaxOffensiveWarcryEffect = globalOutput.TheoreticalMaxOffensiveWarcryEffect * globalOutput["Max"..skillNameVar.."DamageEffect"] - end - - -- combine Ancestral Empowerment with other sources of Slam Ancestral Boost, namely Fist of War, when both active - local function calcCombinedAncestralBoost(skillName, uptimeOverride, additionalSkillName) - globalOutput.CreateWarcryOffensiveCalcSection = true -- labels for the CalcSection - local skillNameVar = skillName:gsub(" ", "") -- Fist Of War -> FistOfWar - local skillNameLabel = skillName:lower() - - globalOutput[skillNameVar.."DamageMultiplier"] = ancestrallyBoostedMoreDamageMulti - -- for CalcSections, set the AncestralEmpowerment damage for mod breakdown - globalOutput[skillNameVar.."CombinedDamageMultiplier"] = globalOutput[skillNameVar.."DamageMultiplier"] - skillNameVar = skillNameVar.."Combined" - local additionalSkillNameVar = additionalSkillName:gsub(" ", "") - local additionalSkillNameLabel = additionalSkillName:lower() - - -- a lot of these are doubled up because it would be very long lines otherwise and hopefully this helps legibility - globalOutput[skillNameVar.."UptimeRatio"] = uptimeOverride or m_min( (1 / globalOutput.Speed) / globalOutput[skillNameVar.."Cooldown"], 1) * 100 - globalOutput[skillNameVar.."UptimeRatio"] = m_min(globalOutput[skillNameVar.."UptimeRatio"] + (globalOutput[additionalSkillNameVar.."UptimeRatio"] or 0), 100) - if globalBreakdown then - globalBreakdown[skillNameVar.."UptimeRatio"] = { - s_format("min( (1 / %.2f) ^8(second per attack)", globalOutput.Speed), - s_format("/ %.2f, 1) ^8("..skillNameLabel.." cooldown)", uptimeOverride and (1 / globalOutput.Speed / (uptimeOverride / 100)) or globalOutput[skillNameVar.."Cooldown"]), - "+", - s_format("min( (1 / %.2f) ^8(second per attack)", globalOutput.Speed), - s_format("/ %.2f, 1) ^8("..additionalSkillNameLabel.." cooldown)", globalOutput[additionalSkillNameVar.."Cooldown"]), - "capped at 100%", - s_format("= %d%%", globalOutput[skillNameVar.."UptimeRatio"]), - } - end - globalOutput["Avg"..skillNameVar.."Damage"] = globalOutput[skillNameVar.."DamageMultiplier"] - globalOutput["Avg"..skillNameVar.."DamageEffect"] = 1 + globalOutput["Avg"..skillNameVar.."Damage"] * (globalOutput[skillNameVar.."UptimeRatio"] / 100) - if globalBreakdown then - globalBreakdown["Avg"..skillNameVar.."DamageEffect"] = { - s_format("1 + (%.2f x %.2f) ^8(combined ancestral boost damage multiplier x uptime ratio)", globalOutput[skillNameVar.."DamageMultiplier"], globalOutput[skillNameVar.."UptimeRatio"] / 100), - s_format("= %.2f", globalOutput["Avg"..skillNameVar.."DamageEffect"]), - } - end - globalOutput["Max"..skillNameVar.."DamageEffect"] = 1 + globalOutput[skillNameVar.."DamageMultiplier"] - if activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then - output[skillNameVar.."DamageEffect"] = globalOutput["Max"..skillNameVar.."DamageEffect"] - else - output[skillNameVar.."DamageEffect"] = globalOutput["Avg"..skillNameVar.."DamageEffect"] - end - calcAreaOfEffect(skillModList, skillCfg, skillData, skillFlags, globalOutput, globalBreakdown) - globalOutput.TheoreticalOffensiveWarcryEffect = globalOutput.TheoreticalOffensiveWarcryEffect * globalOutput["Avg"..skillNameVar.."DamageEffect"] - globalOutput.TheoreticalMaxOffensiveWarcryEffect = globalOutput.TheoreticalMaxOffensiveWarcryEffect * globalOutput["Max"..skillNameVar.."DamageEffect"] - end - globalOutput.FistOfWarCooldown = skillModList:Sum("BASE", cfg, "FistOfWarCooldown") or 0 if skillModList:Flag(cfg, "AncestralEmpowerment") and activeSkill.skillTypes[SkillType.Slam] and not activeSkill.skillTypes[SkillType.Vaal] and not activeSkill.skillTypes[SkillType.OtherThingUsesSkill] then if globalOutput.FistOfWarCooldown ~= 0 then -- get the fist of war calcs in output to use in Empowerment - calcAncestralBoost("Fist Of War", nil, true) + calcAncestralBoost("Fist Of War", ancestrallyBoostedMoreDamageMulti, nil, true) globalOutput.TheoreticalOffensiveWarcryEffect = 1 -- reset effects from FistOfWar calc, we combine later globalOutput.TheoreticalMaxOffensiveWarcryEffect = 1 - calcCombinedAncestralBoost("Ancestral Empowerment", 50, "Fist Of War") + calcCombinedAncestralBoost("Ancestral Empowerment", ancestrallyBoostedMoreDamageMulti, 50, "Fist Of War") globalOutput.FistOfWarUptimeRatio = nil -- hide from CalcSections, but we need it for the combined calc first else - calcAncestralBoost("Ancestral Empowerment", 50) + calcAncestralBoost("Ancestral Empowerment", ancestrallyBoostedMoreDamageMulti, 50) end end -- If Fist of War & Active Skill is a Slam Skill & NOT a Vaal Skill & NOT used by mirage or other if not skillModList:Flag(cfg, "AncestralEmpowerment") and globalOutput.FistOfWarCooldown ~= 0 and activeSkill.skillTypes[SkillType.Slam] and not activeSkill.skillTypes[SkillType.Vaal] and not activeSkill.skillTypes[SkillType.OtherThingUsesSkill] then - calcAncestralBoost("Fist Of War") + calcAncestralBoost("Fist Of War", ancestrallyBoostedMoreDamageMulti) else output.FistOfWarDamageEffect = 1 end @@ -3640,7 +3714,7 @@ function calcs.offence(env, actor, activeSkill) globalOutput.AncestralCallCooldown = skillModList:Sum("BASE", cfg, "AncestralCallCooldown") or 0 -- If Ancestral Call & Active Skill is NOT a Vaal Skill & NOT used by mirage or other & NOT a Channel Skill if globalOutput.AncestralCallCooldown ~= 0 and not activeSkill.skillTypes[SkillType.Vaal] and not activeSkill.skillTypes[SkillType.OtherThingUsesSkill] and not activeSkill.skillTypes[SkillType.Channel] then - calcAncestralBoost("Ancestral Call") + calcAncestralBoost("Ancestral Call", ancestrallyBoostedMoreDamageMulti) else output.AncestralCallDamageEffect = 1 end @@ -4025,7 +4099,30 @@ function calcs.offence(env, actor, activeSkill) --Calculate reservation DPS globalOutput.ReservationDpsMultiplier = 100 / (100 - enemyDB:Sum("BASE", nil, "LifeReservationPercent")) - buildGainTable() + for _, damageType in ipairs(dmgTypeList) do + activeSkill.gainTable[damageType] = {} + for _, toType in ipairs(dmgTypeList) do + local globalGain = m_max(skillModList:Sum("BASE", skillCfg, + "DamageAs"..toType, + "DamageGainAs"..toType, + damageType.."DamageAs"..toType, + damageType.."DamageGainAs"..toType, + isElemental[damageType] and "ElementalDamageAs"..toType or nil, + isElemental[damageType] and "ElementalDamageGainAs"..toType or nil, + damageType ~= "Chaos" and "NonChaosDamageAs"..toType or nil, + damageType ~= "Chaos" and "NonChaosDamageGainAs"..toType or nil), 0) + local skillGain = m_max(skillModList:Sum("BASE", skillCfg, + "SkillDamageGainAs"..toType, + "Skill"..damageType.."DamageGainAs"..toType, + isElemental[damageType] and "SkillElementalDamageGainAs"..toType or nil, + damageType ~= "Chaos" and "SkillNonChaosDamageGainAs"..toType or nil), 0) + if skillModList:Flag(skillCfg, "DamageGainIsOnlyCold") and toType ~= "Cold" then + activeSkill.gainTable[damageType]["Cold"] = (activeSkill.gainTable[damageType]["Cold"] or 0) + (globalGain + skillGain) / 100 + else + activeSkill.gainTable[damageType][toType] = (activeSkill.gainTable[damageType][toType] or 0) + (globalGain + skillGain) / 100 + end + end + end -- Calculate base hit damage for _, damageType in ipairs(dmgTypeList) do @@ -4193,11 +4290,6 @@ function calcs.offence(env, actor, activeSkill) local takenInc = enemyDB:Sum("INC", cfg, "DamageTaken", damageType.."DamageTaken") local takenMore = enemyDB:More(cfg, "DamageTaken", damageType.."DamageTaken") - -- Check if player is supposed to ignore a damage type, or if it's ignored on enemy side - local useThisResist = function(damageType) - return not skillModList:Flag(cfg, "Ignore"..damageType.."Resistance", isElemental[damageType] and "IgnoreElementalResistances" or nil) and not enemyDB:Flag(nil, "SelfIgnore"..damageType.."Resistance") - end - if damageType == "Physical" then local enemyArmourMin = 0 if modDB:GetCondition("CanArmourBreakBelowZero", cfg, nil) then -- check for possibility to break Armour below zero @@ -4234,7 +4326,7 @@ function calcs.offence(env, actor, activeSkill) end -- Find the lowest resist of all the elements and use that if it's lower for _, eleDamageType in ipairs(dmgTypeList) do - if isElemental[eleDamageType] and useThisResist(eleDamageType) and damageType ~= eleDamageType then + if isElemental[eleDamageType] and useThisResist(cfg,eleDamageType) and damageType ~= eleDamageType then local currentElementResist = calcResistForType(eleDamageType, cfg) -- If it's explicitly lower, then use the resist and update which element we're using to account for penetration if skillModList:Flag(cfg, "ChaosDamageUsesHighestResistance") then @@ -4279,21 +4371,12 @@ function calcs.offence(env, actor, activeSkill) takenInc = takenInc + enemyDB:Sum("INC", nil, "TrapMineDamageTaken") end local effMult = (1 + takenInc / 100) * takenMore - local useRes = useThisResist(damageType) + local useRes = useThisResist(cfg,damageType) local effectiveResist = resist - local calcPenResist = function(resist) - return resist > minPen and m_max(resist - pen, minPen) or resist - end local cannotElePenIgnore = isElemental[damageType] and skillModList:Flag(cfg, "CannotElePenIgnore") local ignoreNonNegativeEleRes = isElemental[damageType] and modDB:Flag(cfg, "IgnoreNonNegativeEleRes") - local calcHitResist = function(hitResist) - if not cannotElePenIgnore and ignoreNonNegativeEleRes and hitResist >= 0 then - return 0 - end - return cannotElePenIgnore and hitResist or calcPenResist(hitResist) - end - local normalHitResist = calcHitResist(resist) - local invertedHitResist = calcHitResist(-resist) + local normalHitResist = calcHitResist(resist, minPen, pen, cannotElePenIgnore, ignoreNonNegativeEleRes) + local invertedHitResist = calcHitResist(-resist, minPen, pen, cannotElePenIgnore, ignoreNonNegativeEleRes) local usesResistance = cannotElePenIgnore or useRes if usesResistance then if isElemental[damageType] and invertChance > 0 then @@ -4327,59 +4410,12 @@ function calcs.offence(env, actor, activeSkill) local energyShieldLeech = 0 local manaLeech = 0 - -- Determine base leech value according to resource (using function to avoid repetition) - ---@param resource string "Life" | "Mana" | "EnergyShield" - ---@param dmgType string "Physical" | "Cold" | "Fire" | "Lightning" | "Chaos" - ---@return number - local function getBaseLeech(resource, dmgType) - local leech = 0 - if (not skillModList:Flag(cfg, "Condition:No" .. resource .. "LeechFrom" .. dmgType .. "Damage" )) and not (isElemental[dmgType] and skillModList:Flag(cfg, "No" .. resource .. "LeechFromElementalDamage" )) then - -- Check if converted physical leech (most PoE2 leech is physical only by default) - local convertModName, convertFlag - if isElemental[dmgType] and skillModList:Flag(cfg, resource .. "LeechBasedOnElementalDamage") then - convertFlag = resource .. "LeechBasedOnElementalDamage" - convertModName = "ElementalDamage" .. resource .. "Leech" - elseif skillModList:Flag(cfg, resource .. "LeechBasedOn".. dmgType .. "Damage") then - convertFlag = resource .. "LeechBasedOn" .. dmgType .. "Damage" - convertModName = dmgType .. "Damage" .. resource .. "Leech" - end - if convertModName and convertFlag then - local tempCfg = copyTable(cfg, true) - tempCfg.overrideCond = { ["No" .. resource .. "LeechFromPhysicalDamage"] = false } -- Need to force Condition to `false`, to calculate original phys leech values - local physLeechMods = skillModList:Tabulate("BASE", tempCfg , "PhysicalDamage" .. resource .. "Leech") - for _, entry in ipairs(physLeechMods) do - -- Add new leech mods for that damage type with the same conditions, source, etc. - local newMod = copyTable(entry.mod) - newMod.name = convertModName - -- Tags that specifically disable Physical Damage leech need to be removed - local hasNoPhysLeech, tagIndex = modLib.hasTag(newMod, { type = "Condition", var = "No" .. resource .. "LeechFromPhysicalDamage", neg = true }) - if hasNoPhysLeech then - t_remove(newMod, tagIndex) - end - if not skillModList:ReplaceModInternal(newMod) then -- using `ReplaceModInternal` instead of `ReplaceMod`, so I don't have to unpack the mod first - skillModList:AddMod(newMod) - end - end - end - leech = skillModList:Sum("BASE", cfg, "Damage" .. resource .. "Leech", dmgType.."Damage" .. resource .. "Leech", isElemental[dmgType] and "ElementalDamage" .. resource .. "Leech" or nil) + enemyDB:Sum("BASE", cfg, "SelfDamage" .. resource .. "Leech") / 100 - elseif skillModList:Flag(cfg, "Condition:No" .. resource .. "LeechFrom" .. dmgType .. "Damage" ) then - -- dmgType leech should not apply, but still needs to exist for possible conversion so adding additional condition tag instead - local noLeechFlagTag = { type = "Condition", var = "No" .. resource .. "LeechFrom" .. dmgType .. "Damage", neg = true } - for _, entry in ipairs(skillModList:Tabulate("BASE", cfg, dmgType .. "Damage" .. resource .. "Leech")) do - if not modLib.hasTag(entry.mod, noLeechFlagTag) then - t_insert(entry.mod, noLeechFlagTag ) - end - end - end - return leech and leech or 0 - end - if skillFlags.mine or skillFlags.trap or skillFlags.totem then lifeLeech = skillModList:Sum("BASE", cfg, "DamageLifeLeechToPlayer") else - lifeLeech = getBaseLeech("Life", damageType) - energyShieldLeech = getBaseLeech("EnergyShield", damageType) - manaLeech = getBaseLeech("Mana", damageType) + lifeLeech = getBaseLeech("Life", damageType, skillModList, cfg, enemyDB) + energyShieldLeech = getBaseLeech("EnergyShield", damageType, skillModList, cfg, enemyDB) + manaLeech = getBaseLeech("Mana", damageType, skillModList, cfg, enemyDB) end if ghostReaver and not noLifeLeech then @@ -4465,14 +4501,7 @@ function calcs.offence(env, actor, activeSkill) skillModList:NewMod("Condition:"..highestType.."IsHighestDamageType", "FLAG", true, "Config") end - -- Calculate leech - local function getLeechInstances(amount, total) - if total == 0 then - return 0, 0 - end - local duration = amount / total / data.misc.LeechRateBase - return duration, duration * hitRate - end + --Instant Leech output.LifeLeechInstantProportion = m_max(m_min(skillModList:Sum("BASE", cfg, "InstantLifeLeech") or 0, 100), 0) / 100 @@ -4494,11 +4523,11 @@ function calcs.offence(env, actor, activeSkill) output.EnergyShieldLeech = output.EnergyShieldLeech * (1 - output.EnergyShieldLeechInstantProportion) end - output.LifeLeechDuration, output.LifeLeechInstances = getLeechInstances(output.LifeLeech, globalOutput.Life) + output.LifeLeechDuration, output.LifeLeechInstances = getLeechInstances(output.LifeLeech, globalOutput.Life, hitRate) output.LifeLeechInstantRate = output.LifeLeechInstant * hitRate - output.EnergyShieldLeechDuration, output.EnergyShieldLeechInstances = getLeechInstances(output.EnergyShieldLeech, globalOutput.EnergyShield) + output.EnergyShieldLeechDuration, output.EnergyShieldLeechInstances = getLeechInstances(output.EnergyShieldLeech, globalOutput.EnergyShield, hitRate) output.EnergyShieldLeechInstantRate = output.EnergyShieldLeechInstant * hitRate - output.ManaLeechDuration, output.ManaLeechInstances = getLeechInstances(output.ManaLeech, globalOutput.Mana) + output.ManaLeechDuration, output.ManaLeechInstances = getLeechInstances(output.ManaLeech, globalOutput.Mana, hitRate) output.ManaLeechInstantRate = output.ManaLeechInstant * hitRate -- Calculate gain on hit @@ -5077,7 +5106,7 @@ function calcs.offence(env, actor, activeSkill) ---@param sourceCritChance number ---@param sourceHitDmg number ---@param sourceCritDmg number - ---@param hideFromBreakdown boolean + ---@param hideFromBreakdown boolean? ---@return number baseVal local function calcAilmentDamage(ailment, sourceCritChance, sourceHitDmg, sourceCritDmg, hideFromBreakdown) @@ -5159,6 +5188,7 @@ function calcs.offence(env, actor, activeSkill) return baseVal end + local critMetatable = { __index = |_, key| -> skillCfg.skillCond[key] or cfg.skillCond[key] } ---Calculate global / breakdown values for a damaging ailment ---@param ailment string ---@param ailmentDamageType table @@ -5181,7 +5211,7 @@ function calcs.offence(env, actor, activeSkill) slotName = skillCfg.slotName, flags = bor(ModFlag.Dot, ModFlag.Ailment, band(cfg.flags, ModFlag.WeaponMask), band(cfg.flags, ModFlag.Melee) ~= 0 and ModFlag.MeleeHit or 0), keywordFlags = bor(band(cfg.keywordFlags, bnot(KeywordFlag.Hit)), KeywordFlag[ailment], KeywordFlag.Ailment, KeywordFlag[ailmentDamageType .. "Dot"]), - skillCond = setmetatable({["CriticalStrike"] = true }, { __index = function(table, key) return skillCfg.skillCond[key] or cfg.skillCond[key] end } ), + skillCond = setmetatable({ ["CriticalStrike"] = true },critMetatable ), skillDist = skillCfg.skillDist, } @@ -6197,44 +6227,7 @@ function calcs.offence(env, actor, activeSkill) -- Self hit dmg calcs do - -- Handler functions for self hit sources - local nameToHandler = { - ["Heartbound Loop"] = function(activeSkill, output, breakdown) - if activeSkill.activeEffect.grantedEffect.name == "Summon Skeletons" then - local dmgType, dmgVal - for _, value in ipairs(activeSkill.skillModList:List(nil, "HeartboundLoopSelfDamage")) do -- Combines dmg taken from both ring accounting for catalysts - dmgVal = (dmgVal or 0) + value.baseDamage - dmgType = string.gsub(" "..value.damageType, "%W%l", string.upper):sub(2) -- This assumes both rings deal the same damage type - end - if dmgType and dmgVal then - -- !!!! WARNING !!!! -- - -- applyDmgTakenConversion does NOT consider the "And protect me from Harm" yet - local dmgBreakdown, totalDmgTaken = calcs.applyDmgTakenConversion(activeSkill, output, breakdown, dmgType, dmgVal) - t_insert(dmgBreakdown, 1, s_format("Heartbound Loop base damage: %d", dmgVal)) - t_insert(dmgBreakdown, 2, s_format("")) - t_insert(dmgBreakdown, s_format("Total Heartbound Loop damage taken per cast/attack: %.2f * %d ^8(minions per cast)^7 = %.2f",totalDmgTaken, output.SummonedMinionsPerCast, totalDmgTaken * output.SummonedMinionsPerCast)) - return dmgBreakdown, totalDmgTaken * output.SummonedMinionsPerCast - end - end - end, - ["Trauma"] = function(activeSkill, output, breakdown) - local dmgType = "Physical" - local currentTraumaStacks = math.max(activeSkill.skillModList:Sum("BASE", nil, "Multiplier:TraumaStacks"), 1) - local damagePerTrauma = activeSkill.skillModList:Sum("BASE", nil, "TraumaSelfDamageTakenLife") - local dmgVal = activeSkill.baseSkillModList:Flag(nil, "HasTrauma") and damagePerTrauma * currentTraumaStacks - if dmgType and dmgVal then - -- !!!! WARNING !!!! -- - -- applyDmgTakenConversion does NOT consider the "And protect me from Harm" yet - local dmgBreakdown, totalDmgTaken = calcs.applyDmgTakenConversion(activeSkill, output, breakdown, dmgType, dmgVal) - t_insert(dmgBreakdown, 1, s_format("%d ^8(base %s damage)^7 * %.2f ^8(%s trauma)^7 = %.2f %s damage", damagePerTrauma, dmgType, currentTraumaStacks, activeSkill.skillModList:Sum("BASE", skillCfg, "Multiplier:SustainableTraumaStacks") == currentTraumaStacks and "sustainable" or "current", dmgVal, dmgType)) - t_insert(dmgBreakdown, 2, s_format("")) - t_insert(dmgBreakdown, s_format("Total Trauma damage taken per cast/attack: %.2f ", totalDmgTaken)) - return dmgBreakdown, totalDmgTaken - end - end, - } - - for _, sourceFunc in pairs(nameToHandler) do + for _, sourceFunc in pairs(selfHitHandlers) do local selfHitBreakdown, dmgTaken = sourceFunc(activeSkill, output, breakdown) if dmgTaken then output.SelfHitDamage = (output.SelfHitDamage or 0) + dmgTaken diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 48abc63072..eb0b3e18dc 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -179,6 +179,24 @@ local function addWeaponBaseStats(actor) end end +local function hasActiveSkillExposureSource(activeSkill, modName) + return activeSkill.skillModList and activeSkill.skillCfg + and (activeSkill.skillModList:HasMod("BASE", activeSkill.skillCfg, modName) or activeSkill.skillModList:HasMod("FLAG", activeSkill.skillCfg, "InflictExposure")) + or activeSkill.baseSkillModList + and (activeSkill.baseSkillModList:HasMod("BASE", nil, modName) or activeSkill.baseSkillModList:HasMod("FLAG", nil, "InflictExposure")) +end +local function hasExposureSource(modDB, env, element) + local modName = element .. "ExposureChance" + if modDB:Sum("BASE", nil, modName) > 0 or modDB:HasMod("FLAG", nil, "InflictExposure") then + return true + end + for _, activeSkill in ipairs(env.player.activeSkillList) do + if hasActiveSkillExposureSource(activeSkill, modName) then + return true + end + end + return false +end -- Generic radius/area calculator for a given key prefix (e.g. "Presence", "Surrounded") ---@param actor Actor ---@param key string -- e.g. "Presence" or "Surrounded" @@ -212,6 +230,34 @@ local function calcBuffRadius(actor, key) end end end +local function calculateAttributes(modDB, output, breakdown, condList) + for _ = 1, 2 do -- Calculate twice because of circular dependency (X attribute higher than Y attribute) + for _, stat in ipairs({ "Str", "Dex", "Int" }) do + output[stat] = m_max(round(calcLib.val(modDB, stat)), 0) + if breakdown then + breakdown[stat] = breakdown.simple(nil, nil, output[stat], stat) + end + end + + local stats = { output.Str, output.Dex, output.Int } + table.sort(stats) + output.LowestAttribute = stats[1] + condList["TwoHighestAttributesEqual"] = stats[2] == stats[3] + + condList["DexHigherThanInt"] = output.Dex > output.Int + condList["StrHigherThanInt"] = output.Str > output.Int + condList["IntHigherThanDex"] = output.Int > output.Dex + condList["StrHigherThanDex"] = output.Str > output.Dex + condList["IntHigherThanStr"] = output.Int > output.Str + condList["DexHigherThanStr"] = output.Dex > output.Str + + condList["StrHighestAttribute"] = output.Str >= output.Dex and output.Str >= output.Int + condList["IntHighestAttribute"] = output.Int >= output.Str and output.Int >= output.Dex + condList["DexHighestAttribute"] = output.Dex >= output.Str and output.Dex >= output.Int + condList["IntSingleHighestAttribute"] = output.Int > output.Str and output.Int > output.Dex + condList["DexSingleHighestAttribute"] = output.Dex > output.Str and output.Dex > output.Int + end +end -- Calculate attributes, and set conditions ---@param env Env ---@param actor Actor @@ -424,70 +470,22 @@ local function doActorAttribsConditions(env, actor) end end if env.mode_effective then - local function hasActiveSkillExposureSource(activeSkill, modName) - return activeSkill.skillModList and activeSkill.skillCfg - and (activeSkill.skillModList:HasMod("BASE", activeSkill.skillCfg, modName) or activeSkill.skillModList:HasMod("FLAG", activeSkill.skillCfg, "InflictExposure")) - or activeSkill.baseSkillModList - and (activeSkill.baseSkillModList:HasMod("BASE", nil, modName) or activeSkill.baseSkillModList:HasMod("FLAG", nil, "InflictExposure")) - end - local function hasExposureSource(element) - local modName = element .. "ExposureChance" - if modDB:Sum("BASE", nil, modName) > 0 or modDB:HasMod("FLAG", nil, "InflictExposure") then - return true - end - for _, activeSkill in ipairs(env.player.activeSkillList) do - if hasActiveSkillExposureSource(activeSkill, modName) then - return true - end - end - return false - end - if hasExposureSource("Fire") then + if hasExposureSource(modDB, env, "Fire") then condList["CanApplyFireExposure"] = true modDB:NewMod("Condition:CanApplyFireExposure", "FLAG", true, "Exposure") end - if hasExposureSource("Cold") then + if hasExposureSource(modDB, env, "Cold") then condList["CanApplyColdExposure"] = true modDB:NewMod("Condition:CanApplyColdExposure", "FLAG", true, "Exposure") end - if hasExposureSource("Lightning") then + if hasExposureSource(modDB, env, "Lightning") then condList["CanApplyLightningExposure"] = true modDB:NewMod("Condition:CanApplyLightningExposure", "FLAG", true, "Exposure") end end - -- Calculate attributes - local calculateAttributes = function() - for pass = 1, 2 do -- Calculate twice because of circular dependency (X attribute higher than Y attribute) - for _, stat in ipairs({ "Str", "Dex", "Int" }) do - output[stat] = m_max(round(calcLib.val(modDB, stat)), 0) - if breakdown then - breakdown[stat] = breakdown.simple(nil, nil, output[stat], stat) - end - end - - local stats = { output.Str, output.Dex, output.Int } - table.sort(stats) - output.LowestAttribute = stats[1] - condList["TwoHighestAttributesEqual"] = stats[2] == stats[3] - - condList["DexHigherThanInt"] = output.Dex > output.Int - condList["StrHigherThanInt"] = output.Str > output.Int - condList["IntHigherThanDex"] = output.Int > output.Dex - condList["StrHigherThanDex"] = output.Str > output.Dex - condList["IntHigherThanStr"] = output.Int > output.Str - condList["DexHigherThanStr"] = output.Dex > output.Str - - condList["StrHighestAttribute"] = output.Str >= output.Dex and output.Str >= output.Int - condList["IntHighestAttribute"] = output.Int >= output.Str and output.Int >= output.Dex - condList["DexHighestAttribute"] = output.Dex >= output.Str and output.Dex >= output.Int - condList["IntSingleHighestAttribute"] = output.Int > output.Str and output.Int > output.Dex - condList["DexSingleHighestAttribute"] = output.Dex > output.Str and output.Dex > output.Int - end - end - -- Calculate total attributes - calculateAttributes() + calculateAttributes(modDB, output, breakdown, condList) output.TotalAttr = output.Str + output.Dex + output.Int -- Special case for Devotion / Tribute @@ -1168,6 +1166,19 @@ local function addMinionModifiers(modList, skillCfg, minion) end end +local function setSpectreSource(modList, sourceSkill, activeSkill, castingMinion) + if activeSkill.skillFlags.spectre then + local source = "Spectre:" + if sourceSkill then + source = source .. sourceSkill .. " - " .. castingMinion.minionData.name + else + source = source .. castingMinion.minionData.name + end + for i = 1, #modList do + modList[i].source = source + end + end +end -- Finalises the environment and performs the stat calculations: -- 1. Merges keystone modifiers -- 2. Initialises minion skills @@ -1636,14 +1647,8 @@ function calcs.perform(env, skipEHP) return out end - local function mergeFlasks(flasks, onlyRecovery, checkNonRecoveryFlasksForMinions) - local flaskBuffs = { } - local flaskConditions = {} - local flaskBuffsPerBase = {} - local flaskBuffsNonPlayer = {} - local flaskBuffsPerBaseNonPlayer = {} - local function calcFlaskMods(item, baseName, buffModList, modList, onlyMinion) + local function calcFlaskMods(item, baseName, buffModList, modList, onlyMinion, flaskBuffs, flaskBuffsPerBase, onlyRecovery, checkNonRecoveryFlasksForMinions, flaskBuffsNonPlayer, flaskBuffsPerBaseNonPlayer) local flaskEffectInc = effectInc + item.flaskData.effectInc local flaskEffectIncNonPlayer = effectIncNonPlayer + item.flaskData.effectInc if item.rarity == "MAGIC" and not (item.base.flask.life or item.base.flask.mana) then @@ -1664,7 +1669,7 @@ function calcs.perform(env, skipEHP) mergeBuff(srcList, flaskBuffsPerBase[item.baseName], baseName) end if (not onlyRecovery or checkNonRecoveryFlasksForMinions) and (flasksApplyToMinion or quickSilverAppliesToAllies or (nonUniqueFlasksApplyToMinion and item.rarity ~= "UNIQUE" and item.rarity ~= "RELIC")) then - srcList = new("ModList"):ModList() + local srcList = new("ModList"):ModList() srcList:ScaleAddList(buffModList, effectModNonPlayer) mergeBuff(srcList, flaskBuffsNonPlayer, baseName) mergeBuff(srcList, flaskBuffsPerBaseNonPlayer[item.baseName], baseName) @@ -1695,6 +1700,12 @@ function calcs.perform(env, skipEHP) end end end + local function mergeFlasks(flasks, onlyRecovery, checkNonRecoveryFlasksForMinions) + local flaskBuffs = {} + local flaskConditions = {} + local flaskBuffsPerBase = {} + local flaskBuffsNonPlayer = {} + local flaskBuffsPerBaseNonPlayer = {} for item in pairs(flasks) do flaskBuffsPerBase[item.baseName] = flaskBuffsPerBase[item.baseName] or {} @@ -1710,16 +1721,16 @@ function calcs.perform(env, skipEHP) if onlyRecovery then if item.base.flask.life and not modDB:Flag(nil, "CannotRecoverLifeOutsideLeech") then - calcFlaskMods(item, "LifeFlask", calcFlaskRecovery("Life", item), {}) + calcFlaskMods(item, "LifeFlask", calcFlaskRecovery("Life", item), {}, nil, flaskBuffs, flaskBuffsPerBase, onlyRecovery, checkNonRecoveryFlasksForMinions, flaskBuffsNonPlayer, flaskBuffsPerBaseNonPlayer) end if item.base.flask.mana then - calcFlaskMods(item, "ManaFlask", calcFlaskRecovery("Mana", item), {}) + calcFlaskMods(item, "ManaFlask", calcFlaskRecovery("Mana", item), {}, nil, flaskBuffs, flaskBuffsPerBase, onlyRecovery, checkNonRecoveryFlasksForMinions, flaskBuffsNonPlayer, flaskBuffsPerBaseNonPlayer) end if checkNonRecoveryFlasksForMinions then - calcFlaskMods(item, item.baseName, item.buffModList, item.modList, true) + calcFlaskMods(item, item.baseName, item.buffModList, item.modList, true, flaskBuffs, flaskBuffsPerBase, onlyRecovery, checkNonRecoveryFlasksForMinions, flaskBuffsNonPlayer, flaskBuffsPerBaseNonPlayer) end else - calcFlaskMods(item, item.baseName, item.buffModList, item.modList) + calcFlaskMods(item, item.baseName, item.buffModList, item.modList, nil, flaskBuffs, flaskBuffsPerBase, onlyRecovery, checkNonRecoveryFlasksForMinions, flaskBuffsNonPlayer, flaskBuffsPerBaseNonPlayer) end end if not modDB:Flag(nil, "FlasksDoNotApplyToPlayer") then @@ -1762,12 +1773,8 @@ function calcs.perform(env, skipEHP) output.CharmLimit = charmLimit end - local function mergeCharms(charms) - local charmBuffs = { } - local charmConditions = {} - local charmBuffsPerBase = {} - local function calcCharmMods(item, baseName, buffModList, modList) + local function calcCharmMods(item, baseName, buffModList, modList, charmBuffs, charmBuffsPerBase) local charmEffectInc = effectInc + item.charmData.effectInc if item.rarity == "MAGIC" then charmEffectInc = charmEffectInc + effectIncMagic @@ -1798,8 +1805,11 @@ function calcs.perform(env, skipEHP) mergeBuff(srcList, charmBuffsPerBase[item.baseName], key) end end + local function mergeCharms(charms) + local charmBuffs = {} + local charmConditions = {} + local charmBuffsPerBase = {} - local usedCharms = 0 for item in pairs(charms) do if charmLimit <= 0 then break @@ -1808,7 +1818,7 @@ function calcs.perform(env, skipEHP) charmBuffsPerBase[item.baseName] = charmBuffsPerBase[item.baseName] or {} charmConditions["UsingCharm"] = true charmConditions["Using"..item.baseName:gsub("%s+", "")] = true - calcCharmMods(item, item.baseName, item.buffModList, item.modList) + calcCharmMods(item, item.baseName, item.buffModList, item.modList, charmBuffs, charmBuffsPerBase) end output.EmptyCharms = charmLimit for charmCond, status in pairs(charmConditions) do @@ -1852,6 +1862,11 @@ function calcs.perform(env, skipEHP) end -- Process attribute requirements + local function getSourceNameTooltipFunc(item, reqSource) + return function(tooltip) + env.build.itemsTab:AddItemTooltip(tooltip, item, reqSource.sourceSlot) + end + end do local reqMultItem = calcLib.mod(modDB, nil, "GlobalAttributeRequirements", "GlobalItemAttributeRequirements") local reqMultGem = calcLib.mod(modDB, nil, "GlobalAttributeRequirements", "GlobalGemAttributeRequirements") @@ -1921,10 +1936,8 @@ function calcs.perform(env, skipEHP) } if reqSource.source == "Item" then local item = reqSource.sourceItem - row.sourceName = colorCodes[item.rarity]..item.name - row.sourceNameTooltip = function(tooltip) - env.build.itemsTab:AddItemTooltip(tooltip, item, reqSource.sourceSlot) - end + row.sourceName = colorCodes[item.rarity] .. item.name + row.sourceNameTooltip = getSourceNameTooltipFunc(item, reqSource) elseif reqSource.source == "Gem" then row.sourceName = s_format("%s%s ^7%d/%d", reqSource.sourceGem.color, reqSource.sourceGem.nameSpec, reqSource.sourceGem.level, reqSource.sourceGem.quality) elseif reqSource.source == "Support Gems" then @@ -2572,19 +2585,6 @@ function calcs.perform(env, skipEHP) if activeSkill.minion and activeSkill.minion.activeSkillList then local castingMinion = activeSkill.minion for _, activeMinionSkill in ipairs(activeSkill.minion.activeSkillList) do - local function setSpectreSource(modList, sourceSkill) - if activeSkill.skillFlags.spectre then - local source = "Spectre:" - if sourceSkill then - source = source..sourceSkill.." - "..castingMinion.minionData.name - else - source = source..castingMinion.minionData.name - end - for i = 1, #modList do - modList[i].source = source - end - end - end local skillModList = activeMinionSkill.skillModList local skillCfg = activeMinionSkill.skillCfg for _, buff in ipairs(activeMinionSkill.buffList) do @@ -2662,7 +2662,7 @@ function calcs.perform(env, skipEHP) local srcList = new("ModList"):ModList() srcList:ScaleAddList(buff.modList, mult) srcList:ScaleAddList(extraAuraModList, mult) - setSpectreSource(srcList, buff.name) + setSpectreSource(srcList, buff.name, activeSkill, castingMinion) mergeBuff(srcList, buffs, buff.name) end end @@ -2677,7 +2677,7 @@ function calcs.perform(env, skipEHP) local srcList = new("ModList"):ModList() srcList:ScaleAddList(buff.modList, mult) srcList:ScaleAddList(extraAuraModList, mult) - setSpectreSource(srcList, buff.name) + setSpectreSource(srcList, buff.name, activeSkill, castingMinion) mergeBuff(srcList, minionBuffs, buff.name) end end @@ -2687,7 +2687,7 @@ function calcs.perform(env, skipEHP) local newModList = new("ModList"):ModList() newModList:AddList(buff.modList) newModList:AddList(extraAuraModList) - setSpectreSource(newModList, buff.name) + setSpectreSource(newModList, buff.name, activeSkill, castingMinion) if buffExports["Aura"][buff.name] then buffExports["Aura"][buff.name.."_Debuff"] = buffExports["Aura"][buff.name] end @@ -2726,7 +2726,7 @@ function calcs.perform(env, skipEHP) end end end - setSpectreSource(srcList) + setSpectreSource(srcList, nil, activeSkill, castingMinion) mergeBuff(srcList, buffs, "Totem "..buff.name) end end @@ -3449,7 +3449,6 @@ function calcs.perform(env, skipEHP) return effect end - -- Apply exposures for _, element in ipairs({ "Fire", "Cold", "Lightning" }) do if not modDB:Flag(nil, "ElementalEquilibrium") -- if Elemental Equilibrium isn't active we just process Exposure normally or element == "Fire" and not enemyDB:Flag(nil, "Condition:HitByFireDamage") @@ -3460,17 +3459,16 @@ function calcs.perform(env, skipEHP) local extraExposure = modDB:Sum("BASE", nil, "ExtraExposure", "Extra"..element.."Exposure") local globalExposureEffect = modDB:Sum("INC", nil, element.."ExposureEffect") local exposureEffectOnSelf = enemyDB:More(nil, "ExposureEffectOnSelf") - local function checkExposure(value, modSource, skillExposureEffect) + for _, mod in ipairs(enemyDB:Tabulate("BASE", nil, element .. "Exposure")) do + local skillExposureEffect = getSkillExposureEffect(mod.mod.source, element) -- Resolve each exposure source independently so skill-specific effect only scales the exposure from that skill. + local value = mod.value value = m_floor((value + extraExposure) * ((globalExposureEffect + skillExposureEffect) / 100 + 1) * exposureEffectOnSelf) if value > magnitude then magnitude = value - source = modSource + source = mod.mod.source end end - for _, mod in ipairs(enemyDB:Tabulate("BASE", nil, element.."Exposure")) do - checkExposure(mod.value, mod.mod.source, getSkillExposureEffect(mod.mod.source, element)) - end if magnitude > 0 then local exposureMin = modDB:Override(nil, "ExposureMin") if exposureMin then diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 58c02accc6..2922ef02f6 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -113,11 +113,15 @@ function calcs.initModDB(env, modDB) modDB.conditions["Effective"] = env.mode_effective end +local function capitaliseWord(a, b) + return a .. string.lower(b) +end + local function getCorruptedJewelEffect(env, item, node) if not item or item.type ~= "Jewel" or not item.corrupted or not node or node.containJewelSocket or node.sinister or item.base.subType == "Charm" then return 0 end - local rarity = item.rarity:gsub("(%a)(%u*)", function(a, b) return a..string.lower(b) end) + local rarity = item.rarity:gsub("(%a)(%u*)", capitaliseWord) return env.modDB.multipliers["Corrupted" .. rarity .. "JewelEffect"] or 0 end @@ -623,6 +627,77 @@ local function addBestSupport(supportEffect, appliedSupportList, mode) end end +local function processGrantedEffect(grantedEffect, gemInstance, env, groupCfg, gemIndex, propertyModList, processedSockets, targetListList) + if not grantedEffect or not grantedEffect.support then + return + end + local supportEffect = { + grantedEffect = grantedEffect, + level = gemInstance.level, + quality = gemInstance.quality, + srcInstance = gemInstance, + gemData = gemInstance.gemData, + superseded = false, + isSupporting = {}, + } + if env.mode == "MAIN" then + gemInstance.displayEffect = supportEffect + gemInstance.supportEffect = supportEffect + end + if gemInstance.gemData then + local playerItems = env.player.itemList + local socketedIn = playerItems[groupCfg.slotName] and playerItems[groupCfg.slotName].sockets and playerItems[groupCfg.slotName].sockets[gemIndex] + applyGemMods(supportEffect, socketedIn and getGemModList(env, groupCfg, socketedIn.color, gemIndex) or propertyModList) + if not processedSockets[gemInstance] then + processedSockets[gemInstance] = true + applySocketMods(env, gemInstance.gemData, groupCfg, gemIndex, playerItems[groupCfg.slotName] and playerItems[groupCfg.slotName].name) + -- Keep track of the gem count for each color socketed in this group + groupCfg.intelligenceGems = (groupCfg.intelligenceGems or 0) + (gemInstance.gemData.tags.intelligence and 1 or 0) + groupCfg.dexterityGems = (groupCfg.dexterityGems or 0) + (gemInstance.gemData.tags.dexterity and 1 or 0) + groupCfg.strengthGems = (groupCfg.strengthGems or 0) + (gemInstance.gemData.tags.strength and 1 or 0) + end + end + -- Validate support gem level in case there is no active skill (and no full calculation) + calcLib.validateGemLevel(supportEffect) + + for _, targetList in ipairs(targetListList) do + addBestSupport(supportEffect, targetList, env.mode) + end +end +local function getNormalizedSkillLevel(grantedSkill) + -- Levels in socketGroup.gemList[1].level are normalized + -- grantedSkill.level is not causing group match miss which causes all things that rely on group order to fail + local normalizedGrantedSkill = { + grantedEffect = data.skills[grantedSkill.skillId], + level = grantedSkill.level + } + calcLib.validateGemLevel(normalizedGrantedSkill) + return normalizedGrantedSkill.level +end + +local thornsStats = { "PhysicalMin", "PhysicalMax", "FireMin", "FireMax", "ColdMin", "ColdMax", "LightningMin", "LightningMax", "ChaosMin", "ChaosMax" } +local function modDBHasThornsDamage(modDB) + for _, stat in ipairs(thornsStats) do + local mods = modDB.mods[stat] + if mods then + for _, mod in ipairs(mods) do + if mod.type == "BASE" and band(mod.flags or 0, ModFlag.Thorns) ~= 0 then + return true + end + end + end + end + return modDB.parent and modDBHasThornsDamage(modDB.parent) +end + +local function defaultRadiusJewelFunc(node, out, data) + -- Default function just tallies all stats in radius + if node then + for _, stat in pairs({ "Str", "Dex", "Int" }) do + data[stat] = (data[stat] or 0) + out:Sum("BASE", nil, stat) + end + end +end ---@alias CalcEnvMode "MAIN"|"CALCS"|"CALCULATOR" -- Initialise environment: -- 1. Initialises the player and enemy modifier databases @@ -640,7 +715,6 @@ end ---@return ModDB? cachedEnemyDB ---@return ModDB? cachedMinionDB function calcs.initEnv(build, mode, override, specEnv) - ClearMatchKeywordFlagsCache() -- accelerator variables local cachedPlayerDB = specEnv and specEnv.cachedPlayerDB or nil local cachedEnemyDB = specEnv and specEnv.cachedEnemyDB or nil @@ -1076,14 +1150,7 @@ function calcs.initEnv(build, mode, override, specEnv) end if item and not (node and node.sinister) and ( item.jewelRadiusIndex or (override and override.extraJewelFuncs and #override.extraJewelFuncs > 0) ) then -- Jewel has a radius, add it to the list - local funcList = (item.jewelData and item.jewelData.funcList) or { { type = "Self", func = function(node, out, data) - -- Default function just tallies all stats in radius - if node then - for _, stat in pairs({"Str","Dex","Int"}) do - data[stat] = (data[stat] or 0) + out:Sum("BASE", nil, stat) - end - end - end } } + local funcList = (item.jewelData and item.jewelData.funcList) or { { type = "Self", func = defaultRadiusJewelFunc } } for _, func in ipairs(funcList) do t_insert(env.radiusJewelList, { nodes = node.nodesInRadius and node.nodesInRadius[item.jewelRadiusIndex] or { }, @@ -1622,17 +1689,6 @@ function calcs.initEnv(build, mode, override, specEnv) if not accelerate.skills then if env.mode == "MAIN" then - local function getNormalizedSkillLevel(grantedSkill) - -- Levels in socketGroup.gemList[1].level are normalized - -- grantedSkill.level is not causing group match miss which causes all things that rely on group order to fail - local normalizedGrantedSkill = { - grantedEffect = data.skills[grantedSkill.skillId], - level = grantedSkill.level - } - calcLib.validateGemLevel(normalizedGrantedSkill) - return normalizedGrantedSkill.level - end - -- Process extra skills granted by items or tree nodes local markList = wipeTable(tempTable1) for _, grantedSkill in ipairs(env.grantedSkills) do @@ -1726,16 +1782,6 @@ function calcs.initEnv(build, mode, override, specEnv) end do - local function modDBHasThornsDamage(modDB) - for _, stat in ipairs({ "PhysicalMin", "PhysicalMax", "FireMin", "FireMax", "ColdMin", "ColdMax", "LightningMin", "LightningMax", "ChaosMin", "ChaosMax" }) do - for _, mod in ipairs(modDB.mods[stat] or { }) do - if mod.type == "BASE" and band(mod.flags or 0, ModFlag.Thorns) ~= 0 then - return true - end - end - end - return modDB.parent and modDBHasThornsDamage(modDB.parent) - end local hasThornsDamage = modDBHasThornsDamage(env.modDB) if not hasThornsDamage then for _, socketGroup in pairs(build.skillsTab.socketGroupList) do @@ -1916,50 +1962,13 @@ function calcs.initEnv(build, mode, override, specEnv) gemInstance.supportEffect = nil end if gemInstance.enabled then - local function processGrantedEffect(grantedEffect) - if not grantedEffect or not grantedEffect.support then - return - end - local supportEffect = { - grantedEffect = grantedEffect, - level = gemInstance.level, - quality = gemInstance.quality, - srcInstance = gemInstance, - gemData = gemInstance.gemData, - superseded = false, - isSupporting = { }, - } - if env.mode == "MAIN" then - gemInstance.displayEffect = supportEffect - gemInstance.supportEffect = supportEffect - end - if gemInstance.gemData then - local playerItems = env.player.itemList - local socketedIn = playerItems[groupCfg.slotName] and playerItems[groupCfg.slotName].sockets and playerItems[groupCfg.slotName].sockets[gemIndex] - applyGemMods(supportEffect, socketedIn and getGemModList(env, groupCfg, socketedIn.color, gemIndex) or propertyModList) - if not processedSockets[gemInstance] then - processedSockets[gemInstance] = true - applySocketMods(env, gemInstance.gemData, groupCfg, gemIndex, playerItems[groupCfg.slotName] and playerItems[groupCfg.slotName].name) - -- Keep track of the gem count for each color socketed in this group - groupCfg.intelligenceGems = (groupCfg.intelligenceGems or 0) + (gemInstance.gemData.tags.intelligence and 1 or 0) - groupCfg.dexterityGems = (groupCfg.dexterityGems or 0) + (gemInstance.gemData.tags.dexterity and 1 or 0) - groupCfg.strengthGems = (groupCfg.strengthGems or 0) + (gemInstance.gemData.tags.strength and 1 or 0) - end - end - -- Validate support gem level in case there is no active skill (and no full calculation) - calcLib.validateGemLevel(supportEffect) - - for _, targetList in ipairs(targetListList) do - addBestSupport(supportEffect, targetList, env.mode) - end - end if gemInstance.gemData then - processGrantedEffect(gemInstance.gemData.grantedEffect) + processGrantedEffect(gemInstance.gemData.grantedEffect, gemInstance, env, groupCfg, gemIndex, propertyModList, processedSockets, targetListList) for _, additional in ipairs(gemInstance.gemData.additionalGrantedEffects) do - processGrantedEffect(additional) + processGrantedEffect(additional, gemInstance, env, groupCfg, gemIndex, propertyModList, processedSockets, targetListList) end else - processGrantedEffect(gemInstance.grantedEffect) + processGrantedEffect(gemInstance.grantedEffect, gemInstance, env, groupCfg, gemIndex, propertyModList, processedSockets, targetListList) end end end diff --git a/src/Modules/CalcTools.lua b/src/Modules/CalcTools.lua index 93d0f4903e..52aa97c643 100644 --- a/src/Modules/CalcTools.lua +++ b/src/Modules/CalcTools.lua @@ -14,18 +14,36 @@ calcLib = { } -- Calculate and combine INC/MORE modifiers for the given modifier names function calcLib.mod(modStore, cfg, ...) - return (1 + (modStore:Sum("INC", cfg, ...)) / 100) * modStore:More(cfg, ...) + local inc, more = calcLib.mods(modStore, cfg, ...) + return inc * more end ---Calculates additive and multiplicative modifiers for specified modifier names ---@param modStore table ---@param cfg table ----@param ... string @Mod name(s) ----@return number, number @increased, more +---@param ... string Mod names. Do not call this in a hot loop with more than 5 mod names, as this will break JIT traces. +---@return number increased, number more function calcLib.mods(modStore, cfg, ...) - local inc = 1 + modStore:Sum("INC", cfg, ...) / 100 - local more = modStore:More(cfg, ...) - return inc, more + -- Call separated by argument count so that we can avoid breaking LuaJIT traces. Both calling + -- `select(i, ...)` with a non-const integer and passing `f(...)` will abort a trace. + local n = select('#', ...) + if n == 1 then + local a = ... + return 1 + modStore:Sum("INC", cfg, a) / 100, modStore:More(cfg, a) + elseif n == 2 then + local a, b = ... + return 1 + modStore:Sum("INC", cfg, a, b) / 100, modStore:More(cfg, a, b) + elseif n == 3 then + local a, b, c = ... + return 1 + modStore:Sum("INC", cfg, a, b, c) / 100, modStore:More(cfg, a, b, c) + elseif n == 4 then + local a, b, c, d = ... + return 1 + modStore:Sum("INC", cfg, a, b, c, d) / 100, modStore:More(cfg, a, b, c, d) + elseif n == 5 then + local a, b, c, d, e = ... + return 1 + modStore:Sum("INC", cfg, a, b, c, d, e) / 100, modStore:More(cfg, a, b, c, d, e) + end + return 1 + modStore:Sum("INC", cfg, ...) / 100, modStore:More(cfg, ...) end -- Calculate value @@ -57,24 +75,29 @@ function calcLib.validateGemLevel(gemInstance) end end +local typeExpressionStack = { } + -- Evaluate a skill type postfix expression function calcLib.doesTypeExpressionMatch(checkTypes, skillTypes, minionTypes) - local stack = { } + local stackSize = 0 for _, skillType in pairs(checkTypes) do if skillType == SkillType.OR then - local other = t_remove(stack) - stack[#stack] = stack[#stack] or other + local other = typeExpressionStack[stackSize] + stackSize = stackSize - 1 + typeExpressionStack[stackSize] = typeExpressionStack[stackSize] or other elseif skillType == SkillType.AND then - local other = t_remove(stack) - stack[#stack] = stack[#stack] and other + local other = typeExpressionStack[stackSize] + stackSize = stackSize - 1 + typeExpressionStack[stackSize] = typeExpressionStack[stackSize] and other elseif skillType == SkillType.NOT then - stack[#stack] = not stack[#stack] + typeExpressionStack[stackSize] = not typeExpressionStack[stackSize] else - t_insert(stack, skillTypes[skillType] or (minionTypes and minionTypes[skillType]) or false) + stackSize = stackSize + 1 + typeExpressionStack[stackSize] = skillTypes[skillType] or (minionTypes and minionTypes[skillType]) or false end end - for _, val in ipairs(stack) do - if val then + for index = 1, stackSize do + if typeExpressionStack[index] then return true end end @@ -199,8 +222,10 @@ function calcLib.buildSkillInstanceStats(skillInstance, grantedEffect, statSet, end stats[stat] = (stats[stat] or 0) + statValue end - for _, stat in ipairs(statSet.constantStats or {}) do - stats[stat[1]] = (stats[stat[1]] or 0) + (stat[2] or 0) + if statSet.constantStats then + for _, stat in ipairs(statSet.constantStats) do + stats[stat[1]] = (stats[stat[1]] or 0) + (stat[2] or 0) + end end return stats end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 9408586536..fc55f96af3 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -166,6 +166,37 @@ local mergeStatsSpec = { { key = "CullMultiplier", target = "cullingMulti", mode = "cull" }, } +-- Merge one captured calc pass into the Full DPS totals +local function mergeFullDPSPass(fullDPS, sources, pass) + for _, actor in ipairs(pass.actors) do + local out = actor.out + if out.TotalDPS and out.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = actor.name, dps = out.TotalDPS, count = actor.count, trigger = actor.trigger, skillPart = actor.skillPart }) + fullDPS.combinedDPS = fullDPS.combinedDPS + out.TotalDPS * actor.count + end + for _, stat in ipairs(mergeStatsSpec) do + local value = out[stat.key] + if value then + if stat.mode == "max" then + if value > fullDPS[stat.target] then + fullDPS[stat.target] = value + sources[stat.target] = actor.sourceName + end + elseif stat.mode == "add" then + if value > 0 then + fullDPS[stat.target] = fullDPS[stat.target] + value * (stat.scaled and actor.count or 1) + end + elseif stat.mode == "cull" and value > 1 and value > fullDPS[stat.target] then + fullDPS[stat.target] = value + end + end + end + if out.TotalDot and out.TotalDot > 0 and actor.dotScale then + fullDPS.dotDPS = fullDPS.dotDPS + out.TotalDot * actor.dotScale + end + end +end + -- Tolerant modifier equality for the Full DPS input diff: mod tables are pointer-stable -- across initEnv calls within one build revision, except for a few mods constructed per -- pass (e.g. GemLevel, level-scaled support mods), which are compared structurally instead. @@ -257,43 +288,6 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local sources = { } - local function mergeStats(out, count, sourceName) - for _, stat in ipairs(mergeStatsSpec) do - local value = out[stat.key] - if value then - if stat.mode == "max" then - if value > fullDPS[stat.target] then - fullDPS[stat.target] = value - sources[stat.target] = sourceName - end - elseif stat.mode == "add" then - if value > 0 then - fullDPS[stat.target] = fullDPS[stat.target] + value * (stat.scaled and count or 1) - end - elseif stat.mode == "cull" then - if value > 1 and value > fullDPS[stat.target] then - fullDPS[stat.target] = value - end - end - end - end - end - - -- Merge one captured calc pass into the Full DPS totals - local function mergePass(pass) - for _, actor in ipairs(pass.actors) do - local out = actor.out - if out.TotalDPS and out.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = actor.name, dps = out.TotalDPS, count = actor.count, trigger = actor.trigger, skillPart = actor.skillPart }) - fullDPS.combinedDPS = fullDPS.combinedDPS + out.TotalDPS * actor.count - end - mergeStats(out, actor.count, actor.sourceName) - if out.TotalDot and out.TotalDot > 0 and actor.dotScale then - fullDPS.dotDPS = fullDPS.dotDPS + out.TotalDot * actor.dotScale - end - end - end - for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local uuid = cacheStore and cacheSkillUUID(activeSkill, fullEnv) @@ -313,7 +307,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) -- This skill's own mod list and the coupling surface are unchanged since the -- capture pass, so its results cannot have changed: merge the cached passes for _, pass in ipairs(cachedPasses) do - mergePass(pass) + mergeFullDPSPass(fullDPS, sources, pass) end elseif enabled then local ownRef @@ -376,7 +370,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) sourceName = skillName, dotScale = dotCanStack and activeSkillCount or 1, }) - mergePass(pass) + mergeFullDPSPass(fullDPS, sources, pass) if cacheStore and fullDPSCache.capture and ownRef then cacheStore.snapshots[uuid] = { pass } cacheStore.refs[uuid] = ownRef diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index d4f1ce179d..08c7688826 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -144,6 +144,44 @@ local function parentIndex(proxy, key) end end +-- The functions are created here so that new() does not create closures, which aborts JIT traces +local function makeUnconstructedMeta(class, className) + -- disabled for performance reasons for now + -- return { + -- __index = function(obj, key) + -- if key == className then + -- setmetatable(obj, class) + -- return class[className] + -- end + -- error(s_format( + -- "Object of class '%s' was used before it was constructed (accessed '%s'). Did you forget to call new(\"%s\"):%s()?", + -- className, tostring(key), className, className)) + -- end, + -- } + -- + return class +end + +local function wrapConstructor(class, className) + local originalFunc = class[className] + class[className] = function(self, ...) + -- This will probably break JIT traces? + local ret = originalFunc(self, ...) + if class._parents then + -- Check that the constructors for all parent and superparent classes have been called + for parent in pairs(class._superParents) do + if parent[parent._className] and not self._parentInit[parent] then + error("Parent class '" .. + parent._className .. "' of class '" .. className .. "' must be initialised") + end + end + end + if not ret then + error(string.format("Class %s constructor did not return a value", className)) + end + return ret + end +end ---@generic T ---@param className `T` ---@param extraArg nil Never pass extra parameters. Defined purely to guard against old syntax. @@ -160,17 +198,7 @@ function new(className, extraArg) local object if class[className] then if not rawget(class, "_unconstructedMeta") then - class._unconstructedMeta = { - __index = function(obj, key) - if key == className then - setmetatable(obj, class) - return class[className] - end - error(s_format( - "Object of class '%s' was used before it was constructed (accessed '%s'). Did you forget to call new(\"%s\"):%s()?", - className, tostring(key), className, className)) - end, - } + class._unconstructedMeta = makeUnconstructedMeta(class, className) end object = setmetatable({}, class._unconstructedMeta) else @@ -194,23 +222,7 @@ function new(className, extraArg) end if class[className] and not rawget(class, "_constructorInitialised") then - local originalFunc = class[className] - class[className] = function(self, ...) - local ret = originalFunc(self, ...) - if class._parents then - -- Check that the constructors for all parent and superparent classes have been called - for parent in pairs(class._superParents) do - if parent[parent._className] and not self._parentInit[parent] then - error("Parent class '" .. - parent._className .. "' of class '" .. className .. "' must be initialised") - end - end - end - if not ret then - error(string.format("Class %s constructor did not return a value", className)) - end - return ret - end + wrapConstructor(class, className) class._constructorInitialised = true end return object @@ -793,31 +805,32 @@ function triangular(n) end -- Formats "1234.56" -> "1,234.5" -function formatNumSep(str) - return string.gsub(str, "(%^?x?%x?%x?%x?%x?%x?%x?-?%d+%.?%d+)", function(m) - local colour = m:match("(^x%x%x%x%x%x%x)") or m:match("(%^%d)") or "" - local str = m:gsub("(^x%x%x%x%x%x%x)", ""):gsub("(%^%d)", "") - if str == "" or (colour == "" and m:match("%^")) then -- return if we have an invalid color code or a completely stripped number. - return m - end - local x, y, minus, integer, fraction = str:find("(-?)(%d+)(%.?%d*)") - if main.showThousandsSeparators then - rev1kSep = utf8.reverse(main.thousandsSeparator) - integer = utf8.reverse(utf8.gsub(utf8.reverse(integer), "(%d%d%d)", "%1"..rev1kSep)) - -- There will be leading separators if the number of digits are divisible by 3 - -- This checks for their presence and removes them - -- Don't use patterns here because thousandsSeparator can be a pattern control character, and will crash if used - if main.thousandsSeparator ~= "" then - local thousandsSeparator = utf8.find(integer, rev1kSep, 1, 2) - if thousandsSeparator and thousandsSeparator == 1 then - integer = utf8.sub(integer, 2) - end +local function formatNumSepInner(m) + local colour = m:match("(^x%x%x%x%x%x%x)") or m:match("(%^%d)") or "" + local str = m:gsub("(^x%x%x%x%x%x%x)", ""):gsub("(%^%d)", "") + if str == "" or (colour == "" and m:match("%^")) then -- return if we have an invalid color code or a completely stripped number. + return m + end + local x, y, minus, integer, fraction = str:find("(-?)(%d+)(%.?%d*)") + if main.showThousandsSeparators then + rev1kSep = utf8.reverse(main.thousandsSeparator) + integer = utf8.reverse(utf8.gsub(utf8.reverse(integer), "(%d%d%d)", "%1" .. rev1kSep)) + -- There will be leading separators if the number of digits are divisible by 3 + -- This checks for their presence and removes them + -- Don't use patterns here because thousandsSeparator can be a pattern control character, and will crash if used + if main.thousandsSeparator ~= "" then + local thousandsSeparator = utf8.find(integer, rev1kSep, 1, 2) + if thousandsSeparator and thousandsSeparator == 1 then + integer = utf8.sub(integer, 2) end - else - integer = utf8.reverse(utf8.gsub(utf8.reverse(integer), "(%d%d%d)", "%1")) end - return colour..minus..integer..utf8.gsub(fraction, "%.", main.decimalSeparator) - end) + else + integer = utf8.reverse(utf8.gsub(utf8.reverse(integer), "(%d%d%d)", "%1")) + end + return colour .. minus .. integer .. utf8.gsub(fraction, "%.", main.decimalSeparator) +end +function formatNumSep(str) + return string.gsub(str, "(%^?x?%x?%x?%x?%x?%x?%x?-?%d+%.?%d+)", formatNumSepInner) end function getFormatNumSep(dec) diff --git a/src/Modules/ItemTools.lua b/src/Modules/ItemTools.lua index 45e87a66d9..589a70404b 100644 --- a/src/Modules/ItemTools.lua +++ b/src/Modules/ItemTools.lua @@ -73,6 +73,55 @@ function itemLib.isZeroValueLine(line) return line:match("^%+?0%%? ") or (line:match(" %+?0%%? ") and not line:match("0 to [1-9]") and not line:match("0%% to %d+%%")) or line:match(" 0%-0 ") or line:match(" 0 to 0 ") end +local function replaceNthInstance(input, pattern, replacement, n) + local count = 0 + return input:gsub(pattern, function(match) + count = count + 1 + if count == n then + return replacement + else + return match + end + end) +end +-- check combinations recursively largest to smallest +local function checkSubstitutionCombinations(i, numSubstitutions, indices, line, values) + if #indices == numSubstitutions then + local modifiedLine = line + local substituted = 0 + for _, i in ipairs(indices) do + modifiedLine = replaceNthInstance(modifiedLine, "#", values[i], i - substituted) + substituted = substituted + 1 + end + + -- Check if the modified line matches any scalability data + local key = modifiedLine:gsub("+#", "#") + if data.modScalability[key] then + -- Return modified line and remaining values (those not substituted) + local remainingValues = {} + local used = {} + for _, index in ipairs(indices) do + used[index] = true + end + for i, value in ipairs(values) do + if not used[i] then + table.insert(remainingValues, value) + end + end + return modifiedLine, remainingValues + end + return + end + for j = i, #values do + table.insert(indices, j) + local modifiedLine, remainingValues = checkSubstitutionCombinations(j + 1, numSubstitutions, indices, line, values) + if modifiedLine then + return modifiedLine, remainingValues + end + table.remove(indices) + end +end + -- Apply range value (0 to 1) to a modifier that has a range: "(x-x)" or "(x-x) to (x-x)" ---@param line string ---@param range number|number[] @@ -80,21 +129,21 @@ end ---@param baseValueScalar number? function itemLib.applyRange(line, range, valueScalar, baseValueScalar) -- stripLines down to # in place of any number and store numbers inside values also remove all + signs are kept if value is positive - local values = { } + local values = {} local rangeIndex = 0 local ranges = type(range) == "table" and range local strippedLine = line:gsub("([%+-]?)%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)", function(sign, min, max) - rangeIndex = rangeIndex + 1 - local valueRange = ranges and (ranges[rangeIndex] or 0.5) or range - local value = min + valueRange * (tonumber(max) - min) - if sign == "-" then value = value * -1 end - return (sign == "+" and value > 0 ) and sign..tostring(value) or tostring(value) - end) - :gsub("%-(%d+%.?%d*%%) (%a+)", antonymFunc) - :gsub("(%-?%d+%.?%d*)", function(value) - t_insert(values, value) - return "#" - end) + rangeIndex = rangeIndex + 1 + local valueRange = ranges and (ranges[rangeIndex] or 0.5) or range + local value = min + valueRange * (tonumber(max) - min) + if sign == "-" then value = value * -1 end + return (sign == "+" and value > 0) and sign .. tostring(value) or tostring(value) + end) + :gsub("%-(%d+%.?%d*%%) (%a+)", antonymFunc) + :gsub("(%-?%d+%.?%d*)", function(value) + t_insert(values, value) + return "#" + end) --- Takes a completely strippedLine where all values and ranges are replaced with a # + signs are kept for consistency upon re-substitution. --- This will then substitute back in the values until a line in scalabilityData is found this start with substituting everything and until none. @@ -104,58 +153,10 @@ function itemLib.applyRange(line, range, valueScalar, baseValueScalar) ---@return scalableLine line with only scalableValues replaced with # ---@return scalableValues values which can be scaled and added into scalableLine in place of a # local function findScalableLine(line, values) - local function replaceNthInstance(input, pattern, replacement, n) - local count = 0 - return input:gsub(pattern, function(match) - count = count + 1 - if count == n then - return replacement - else - return match - end - end) - end - - -- check combinations recursively largest to smallest - local function checkSubstitutionCombinations(i, numSubstitutions, indices) - if #indices == numSubstitutions then - local modifiedLine = line - local substituted = 0 - for _, i in ipairs(indices) do - modifiedLine = replaceNthInstance(modifiedLine, "#", values[i], i - substituted) - substituted = substituted + 1 - end - - -- Check if the modified line matches any scalability data - local key = modifiedLine:gsub("+#", "#") - if data.modScalability[key] then - -- Return modified line and remaining values (those not substituted) - local remainingValues = {} - local used = { } - for _, index in ipairs(indices) do - used[index] = true - end - for i, value in ipairs(values) do - if not used[i] then - table.insert(remainingValues, value) - end - end - return modifiedLine, remainingValues - end - return - end - for j = i, #values do - table.insert(indices, j) - local modifiedLine, remainingValues = checkSubstitutionCombinations(j + 1, numSubstitutions, indices) - if modifiedLine then - return modifiedLine, remainingValues - end - table.remove(indices) - end - end - + local indices for i = #values, 1, -1 do - local modifiedLine, remainingValues = checkSubstitutionCombinations(1, i, {}) + indices = wipeTable(indices) + local modifiedLine, remainingValues = checkSubstitutionCombinations(1, i, indices, line, values) if modifiedLine then return modifiedLine, remainingValues end diff --git a/src/Modules/ModTools.lua b/src/Modules/ModTools.lua index 82e13fcf8a..9f1a14148c 100644 --- a/src/Modules/ModTools.lua +++ b/src/Modules/ModTools.lua @@ -216,6 +216,19 @@ function modLib.formatTags(tagList) return ret or "-" end +local function paramNameSort(a, b) + if type(a) == "number" and type(b) == "number" then + return a < b + end + if type(a) == "number" then + return true + end + if type(b) == "number" then + return false + end + return a < b +end + function modLib.formatValue(value) if type(value) ~= "table" then return tostring(value) @@ -230,18 +243,7 @@ function modLib.formatValue(value) end end - t_sort(paramNames, function (a, b) - if type(a) == "number" and type(b) == "number" then - return a < b - end - if type(a) == "number" then - return true - end - if type(b) == "number" then - return false - end - return a < b - end) + t_sort(paramNames, paramNameSort) if haveType then t_insert(paramNames, 1, "type")