diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua index 58529fb285d..e6ffafddd86 100644 --- a/src/Classes/CompareEntry.lua +++ b/src/Classes/CompareEntry.lua @@ -344,14 +344,14 @@ function CompareEntryClass:RefreshSkillSelectControls(controls, mainGroup, suffi if not activeEffect then return end -- Skill parts - if activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1 then + if activeSkill.skillPartList and #activeSkill.skillPartList > 1 then controls.mainSkillPart.shown = true wipeTable(controls.mainSkillPart.list) - for i, part in ipairs(activeEffect.grantedEffect.parts) do + for i, part in ipairs(activeSkill.skillPartList) do t_insert(controls.mainSkillPart.list, { val = i, label = part.name }) end controls.mainSkillPart.selIndex = activeEffect.srcInstance["skillPart"..suffix] or 1 - local selectedPart = activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex] + local selectedPart = activeSkill.skillPartList[controls.mainSkillPart.selIndex] if selectedPart and selectedPart.stages then controls.mainSkillStageCount.shown = true controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or selectedPart.stagesMin or 1) @@ -365,7 +365,7 @@ function CompareEntryClass:RefreshSkillSelectControls(controls, mainGroup, suffi end -- Stage count (for multi-stage skills without parts) - if activeSkill.skillFlags and activeSkill.skillFlags.multiStage and not (activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1) then + if activeSkill.skillFlags and activeSkill.skillFlags.multiStage and not (activeSkill.skillPartList and #activeSkill.skillPartList > 1) then controls.mainSkillStageCount.shown = true controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMin or 1) end diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index e87c36ecbc2..3ada9855669 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -71,6 +71,19 @@ function GemSelectClass:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) local gemList = self.skillsTab.displayGroup.gemList local displayGemList = self.skillsTab.displayGroup.displayGemList local oldGem + local skillPartSelections = { } + -- Some candidate supports can temporarily make an active skill incompatible + -- with its selected part. The comparison calculation must not persist the + -- resulting fallback to the default part in the actual build. + for _, socketGroup in ipairs(self.skillsTab.socketGroupList) do + for _, gemInstance in ipairs(socketGroup.gemList) do + t_insert(skillPartSelections, { + gemInstance = gemInstance, + skillPart = gemInstance.skillPart, + skillPartCalcs = gemInstance.skillPartCalcs, + }) + end + end -- the imbuedSupport control actively switches to the latest index of the current displayGroup's gemList so we can use the canSupport filtering if self.imbuedSelect then @@ -99,6 +112,10 @@ function GemSelectClass:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) gemInstance.displayEffect = nil -- Calculate the impact of using this gem local output = calcFunc(nil, useFullDPS) + for _, selection in ipairs(skillPartSelections) do + selection.gemInstance.skillPart = selection.skillPart + selection.gemInstance.skillPartCalcs = selection.skillPartCalcs + end -- Put the original gem back into the list if oldGem then gemInstance.gemData = oldGem.gemData diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index f1326ddc980..575dbf65662 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -416,6 +416,33 @@ will automatically apply to the skill.]] return label end + self.controls.brandRecallSourceLabel = new("LabelControl"):LabelControl({ "TOPLEFT", self.controls.imbuedSupportLabel, "BOTTOMLEFT" }, { 0, 12, 0, 16 }, "^7Recalled brand:") + self.controls.brandRecallSourceLabel.shown = function() + for _, gem in ipairs(self.displayGroup and self.displayGroup.gemList or {}) do + local effect = gem.grantedEffect or (gem.gemData and gem.gemData.grantedEffect) + if effect and effect.id == "BrandRecall" then + return true + end + end + return false + end + self.controls.brandRecallSource = new("DropDownControl"):DropDownControl({ "LEFT", self.controls.brandRecallSourceLabel, "RIGHT" }, { 8, 0, 300, 20 }, {}, function(index, value) + local source = value.group + if source and not source.brandRecallId then + -- Keep references stable when groups are reordered or removed. Include dangling + -- references when allocating IDs so a deleted source cannot be replaced silently. + local nextId = 1 + for _, group in ipairs(self.socketGroupList) do + nextId = math.max(nextId, (group.brandRecallId or 0) + 1, (group.brandRecallSourceId or 0) + 1) + end + source.brandRecallId = nextId + end + self.displayGroup.brandRecallSourceId = source and source.brandRecallId + self:AddUndoState() + self.build.buildFlag = true + end) + self.controls.brandRecallSource.tooltipText = "Adds 20% of the selected brand's cost per active brand to Brand Recall's cost and uses that brand for recalled DPS." + -- Scroll bar self.controls.scrollBarH = new("ScrollBarControl"):ScrollBarControl(nil, {0, 0, 0, 18}, 100, "HORIZONTAL", true) @@ -447,6 +474,8 @@ function SkillsTabClass:LoadSkill(node, skillSetId) socketGroup.enabled = node.attrib.active == "true" or node.attrib.enabled == "true" socketGroup.includeInFullDPS = node.attrib.includeInFullDPS and node.attrib.includeInFullDPS == "true" socketGroup.groupCount = tonumber(node.attrib.groupCount) + socketGroup.brandRecallId = tonumber(node.attrib.brandRecallId) + socketGroup.brandRecallSourceId = tonumber(node.attrib.brandRecallSourceId) socketGroup.label = node.attrib.label socketGroup.slot = node.attrib.slot socketGroup.source = node.attrib.source @@ -590,6 +619,8 @@ function SkillsTabClass:Save(xml) enabled = tostring(socketGroup.enabled), includeInFullDPS = tostring(socketGroup.includeInFullDPS), groupCount = socketGroup.groupCount ~= nil and tostring(socketGroup.groupCount), + brandRecallId = socketGroup.brandRecallId and tostring(socketGroup.brandRecallId), + brandRecallSourceId = socketGroup.brandRecallSourceId and tostring(socketGroup.brandRecallSourceId), label = socketGroup.label, slot = socketGroup.slot, source = socketGroup.source, @@ -686,11 +717,36 @@ function SkillsTabClass:Draw(viewPort, inputEvents) self.anchorGroupDetail:SetAnchor("TOPLEFT",self.controls.groupList,"TOPRIGHT", 20, 0) end + self:UpdateBrandRecallSourceList() self:UpdateGemSlots() self:DrawControls(viewPort) end +function SkillsTabClass:UpdateBrandRecallSourceList() + local list = { { label = "None" } } + local selected = 1 + local sourceId = self.displayGroup and self.displayGroup.brandRecallSourceId + for index, group in ipairs(self.socketGroupList) do + for _, skill in ipairs(group.displaySkillList or {}) do + if skill.skillTypes[SkillType.Brand] and not skill.skillFlags.disable then + t_insert(list, { label = index .. ": " .. (group.displayLabel or skill.activeEffect.grantedEffect.name), group = group }) + if sourceId and group.brandRecallId == sourceId then + selected = #list + end + break + end + end + end + if sourceId and selected == 1 then + t_insert(list, { label = "Selected brand group unavailable" }) + selected = #list + end + self.controls.brandRecallSource:SetList(list) + self.controls.brandRecallSource.selIndex = selected + self.anchorGemSlots:SetAnchor("TOPLEFT", self.controls.imbuedSupportLabel, "BOTTOMLEFT", 0, self.controls.brandRecallSourceLabel:IsShown() and 62 or 30) +end + function SkillsTabClass:CopySocketGroup(socketGroup) local skillText = "" if socketGroup.label and socketGroup.label:match("%S") then diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index c975e8c33ab..72659d097c6 100755 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -6278,8 +6278,7 @@ c["20% chance for Energy Shield Recharge to start when you Block 35% chance for c["20% chance for Energy Shield Recharge to start when you Kill an Enemy"]={{[1]={[1]={type="Condition",var="KilledRecently"},flags=0,keywordFlags=0,name="EnergyShield",type="BASE",value=20}}," for Recharge to start "} c["20% chance for Energy Shield Recharge to start when you Kill an Enemy 40% less Energy Shield Recharge Rate"]={{[1]={[1]={type="Condition",var="KilledRecently"},flags=0,keywordFlags=0,name="EnergyShield",type="BASE",value=20}}," for Recharge to start 40% less Energy Shield Recharge Rate "} c["20% chance for Poisons inflicted with this Weapon to deal 300% more Damage"]={{[1]={[1]={type="Condition",var="{Hand}Attack"},[2]={skillType=1,type="SkillType"},flags=0,keywordFlags=2097152,name="Damage",type="MORE",value=60}},nil} -c["20% chance for Skills to not consume a Cooldown on use"]={{}," for Skills to not consume a Cooldown on use "} -c["20% chance for Skills to not consume a Cooldown on use Limited to 1 Runegraft of Time"]={{}," for Skills to not consume a Cooldown on use Limited to 1 Runegraft of Time "} +c["20% chance for Skills to not consume a Cooldown on use"]={{[1]={flags=0,keywordFlags=0,name="CooldownNotConsumedChance",type="BASE",value=20}},nil} c["20% chance for used Retaliation Skills to remain Usable and not consume a Cooldown Use"]={{[1]={flags=0,keywordFlags=0,name="AdditionalCooldownUses",type="BASE",value=20}}," for used Retaliation Skills to remain Usable and not consume a "} c["20% chance on Hit to remove all Impales from Enemy"]={{}," to remove all Impales from Enemy "} c["20% chance on Hit to remove all Impales from Enemy Impales removed this way multiply their Reflected Damage for this Hit by the number of Hits they have left"]={{[1]={flags=4,keywordFlags=0,name="Damage",type="BASE",value=20}}," to remove all Impales from Enemy Impales removed this way multiply their Reflected for this Hit by the number of Hits they have left "} @@ -10003,8 +10002,8 @@ c["5% increased Poison Duration"]={{[1]={flags=0,keywordFlags=0,name="EnemyPoiso c["5% increased Poison Duration for each Poison you have inflicted Recently, up"]={{[1]={[1]={type="Multiplier",var="PoisonAppliedRecently"},flags=0,keywordFlags=0,name="EnemyPoisonDuration",type="INC",value=5}}," , up "} c["5% increased Poison Duration for each Poison you have inflicted Recently, up to a maximum of 100%"]={{[1]={[1]={globalLimit=100,globalLimitKey="DurationPerPoisonRecently",type="Multiplier",var="PoisonAppliedRecently"},flags=0,keywordFlags=0,name="EnemyPoisonDuration",type="INC",value=5}},nil} c["5% increased Projectile Damage per Power Charge"]={{[1]={[1]={type="Multiplier",var="PowerCharge"},flags=1024,keywordFlags=0,name="Damage",type="INC",value=5}},nil} -end)();(function() c["5% increased Projectile Speed"]={{[1]={flags=0,keywordFlags=0,name="ProjectileSpeed",type="INC",value=5}},nil} +end)();(function() c["5% increased Projectile Speed per Frenzy Charge"]={{[1]={[1]={type="Multiplier",var="FrenzyCharge"},flags=0,keywordFlags=0,name="ProjectileSpeed",type="INC",value=5}},nil} c["5% increased Quantity of Gold Dropped by Slain Enemies"]={{}," Quantity of Gold Dropped by Slain Enemies "} c["5% increased Quantity of Items found"]={{[1]={flags=0,keywordFlags=0,name="LootQuantity",type="INC",value=5}},nil} @@ -15005,8 +15004,8 @@ c["Damage of Enemies Hitting you is Unlucky"]={nil,"Damage of Enemies Hitting yo c["Damage of Enemies Hitting you is Unlucky Damage with Hits is Unlucky"]={nil,"Damage of Enemies Hitting you is Unlucky Damage with Hits is Unlucky "} c["Damage of Enemies Hitting you is Unlucky while you are Cursed with Vulnerability"]={nil,"Damage of Enemies Hitting you is Unlucky while you are Cursed with Vulnerability "} c["Damage of Enemies Hitting you is Unlucky while you are Cursed with Vulnerability You count as on Full Life while you are Cursed with Vulnerability"]={nil,"Damage of Enemies Hitting you is Unlucky while you are Cursed with Vulnerability You count as on Full Life while you are Cursed with Vulnerability "} -end)();(function() c["Damage of Enemies Hitting you is Unlucky while you are on Full Life"]={nil,"Damage of Enemies Hitting you is Unlucky while you are on Full Life "} +end)();(function() c["Damage of Enemies Hitting you is Unlucky while you are on Low Life"]={nil,"Damage of Enemies Hitting you is Unlucky while you are on Low Life "} c["Damage of Enemies Hitting you is Unlucky while you have a Magic Ring Equipped"]={nil,"Damage of Enemies Hitting you is Unlucky while you have a Magic Ring Equipped "} c["Damage of Enemies Hitting you is Unlucky while you have a Magic Ring Equipped You are Hexproof if you have a Magic Ring in right slot"]={nil,"Damage of Enemies Hitting you is Unlucky while you have a Magic Ring Equipped You are Hexproof if you have a Magic Ring in right slot "} @@ -20007,8 +20006,8 @@ c["Skills Socketed in your Boots are Supported by level 20 High-Impact Mine"]={{ c["Skills Socketed in your Boots are Supported by level 20 Hypothermia"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportHypothermia"}}},nil} c["Skills Socketed in your Boots are Supported by level 20 Ice Bite"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportIceBite"}}},nil} c["Skills Socketed in your Boots are Supported by level 20 Ignite Proliferation"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportIgniteProliferation"}}},nil} -end)();(function() c["Skills Socketed in your Boots are Supported by level 20 Immolate"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportImmolate"}}},nil} +end)();(function() c["Skills Socketed in your Boots are Supported by level 20 Impale"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportImpale"}}},nil} c["Skills Socketed in your Boots are Supported by level 20 Increased Area of Effect"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportIncreasedAreaOfEffect"}}},nil} c["Skills Socketed in your Boots are Supported by level 20 Increased Critical Damage"]={{[1]={[1]={slotName="Boots",type="SocketedIn"},flags=0,keywordFlags=0,name="ExtraSupport",type="LIST",value={appliesToGrantedSkills=true,level=20,skillId="SupportIncreasedCriticalDamage"}}},nil} diff --git a/src/Data/SkillStatMap.lua b/src/Data/SkillStatMap.lua index 880e8c8e421..cf7ac440342 100644 --- a/src/Data/SkillStatMap.lua +++ b/src/Data/SkillStatMap.lua @@ -2172,6 +2172,9 @@ return { ["brand_cannot_be_recalled"] = { flag("Condition:CannotRecallBrand"), }, +["brand_recall_spend_%_of_recalled_brands_cost"] = { + skill("brandRecallCostPercent", nil), +}, -- Banner ["banner_buff_effect_+%_final_per_resource"] = { mod("AuraEffect", "MORE", nil, 0, 0, { type = "Multiplier", var = "BannerValour" }, { type = "Condition", var = "BannerPlanted" }), diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 3ad9d8eafff..7c4afab75d4 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -1655,23 +1655,23 @@ function buildMode:RefreshSkillSelectControls(controls, mainGroup, suffix) local activeSkill = displaySkillList[mainActiveSkill] local activeEffect = activeSkill.activeEffect if activeEffect then - if activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1 then + if activeSkill.skillPartList and #activeSkill.skillPartList > 1 then controls.mainSkillPart.shown = true wipeTable(controls.mainSkillPart.list) - for i, part in ipairs(activeEffect.grantedEffect.parts) do + for i, part in ipairs(activeSkill.skillPartList) do t_insert(controls.mainSkillPart.list, { val = i, label = part.name }) end controls.mainSkillPart.selIndex = activeEffect.srcInstance["skillPart"..suffix] or 1 - if activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex].stages then + if activeSkill.skillPartList[controls.mainSkillPart.selIndex].stages then controls.mainSkillStageCount.shown = true - controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMax or activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex].stagesMin or 1) + controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMax or activeSkill.skillPartList[controls.mainSkillPart.selIndex].stagesMin or 1) end end if activeSkill.skillFlags.mine then controls.mainSkillMineCount.shown = true controls.mainSkillMineCount.buf = tostring(activeEffect.srcInstance["skillMineCount"..suffix] or "") end - if activeSkill.skillFlags.multiStage and not (activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1) then + if activeSkill.skillFlags.multiStage and not (activeSkill.skillPartList and #activeSkill.skillPartList > 1) then controls.mainSkillStageCount.shown = true controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMax or activeSkill.skillData.stagesMin or 1) end @@ -2029,6 +2029,12 @@ function buildMode:AddDisplayStatList(statList, actor, actorName) InsertIfNew(self.controls.warnings.lines, line) end end + for pool, warningFlag in pairs({["Life"] = "LifeCostChainWarningList", ["Mana"] = "ManaCostChainWarningList", ["Rage"] = "RageCostChainWarningList", ["Energy Shield"] = "ESCostChainWarningList", ["Unreserved Life %"] = "LifePercentCostChainWarningList", ["Unreserved Mana %"] = "ManaPercentCostChainWarningList"}) do + if actor.output[warningFlag] then + local resource = actor.output.EnergyShieldProtectsMana and pool == "Mana" and "Energy Shield and Mana" or pool + InsertIfNew(self.controls.warnings.lines, "Estimated total spending per use exceeds your available "..resource.." before recovery: "..table.concat(actor.output[warningFlag], ", ")) + end + end for pool, warningFlag in pairs({["Unreserved life"] = "LifePercentCostPercentCostWarningList", ["Unreserved Mana"] = "ManaPercentCostPercentCostWarningList"}) do if actor.output[warningFlag] then local line = "You do not have enough ".. pool .."% to use: " diff --git a/src/Modules/BuildDisplayStats.lua b/src/Modules/BuildDisplayStats.lua index 950c579f09a..4de3e59852d 100644 --- a/src/Modules/BuildDisplayStats.lua +++ b/src/Modules/BuildDisplayStats.lua @@ -58,9 +58,10 @@ local displayStats = { { stat = "Speed", label = "Attack Rate", fmt = ".2f", compPercent = true, flag = "attack", condFunc = function(v, o) return v > 0 and (o.TriggerTime or 0) == 0 end, breakdown = "Speed", modNames = { "Speed" }, ignoredSections = { "Inc. Cast Speed", "More Cast Speed", } }, { stat = "KineticFusilladeMaxEffectiveAPS", label = "Max Eff. KF Attack Rate", fmt = ".2f", compPercent = true, flag = "attack", condFunc = function(v) return v > 0 end }, { stat = "Speed", label = "Cast Rate", fmt = ".2f", compPercent = true, flag = "spell", condFunc = function(v, o) return v > 0 and (o.TriggerTime or 0) == 0 end, modNames = { "Speed" } }, - { stat = "Speed", label = "Effective Trigger Rate", fmt = ".2f", compPercent = true, notFlag = "skipEffectiveRate", condFunc = function(v,o) return (o.TriggerTime or 0) ~= 0 end }, + { stat = "Speed", label = "Effective Trigger Rate", fmt = ".2f", compPercent = true, notFlag = { "skipEffectiveRate", "recalled" }, condFunc = function(v,o) return (o.TriggerTime or 0) ~= 0 end }, + { stat = "RecalledSkillRate", label = "Recalled Activations/s", fmt = ".2f", flag = "recalled", breakdown = "RecalledSkillRate" }, { stat = "WarcryCastTime", label = "Cast Time", fmt = ".2fs", compPercent = true, lowerIsBetter = true, flag = "warcry" }, - { stat = "HitSpeed", label = "Hit Rate", fmt = ".2f", compPercent = true, condFunc = function(v,o) return not o.TriggerTime end }, + { stat = "HitSpeed", label = "Hit Rate", fmt = ".2f", compPercent = true, notFlag = "recalledBrand", condFunc = function(v,o) return not o.TriggerTime end }, { stat = "HitTime", label = "Channel Time", fmt = ".2fs", compPercent = true, flag = "channelRelease", lowerIsBetter = true, condFunc = function(v,o) return not o.TriggerTime end }, { stat = "ChannelTimeToTrigger", label = "Channel Time", fmt = ".2fs", compPercent = true, lowerIsBetter = true, }, { stat = "TrapThrowingTime", label = "Trap Throwing Time", fmt = ".2fs", compPercent = true, lowerIsBetter = true, }, @@ -115,20 +116,26 @@ local displayStats = { { stat = "AreaOfEffectRadiusMetres", label = "AoE Radius", fmt = ".1fm", breakdown = "AreaOfEffectRadius", modNames = { "AreaOfEffect" } }, { stat = "BrandAttachmentRangeMetre", label = "Attachment Range", fmt = ".1fm", flag = "brand" }, { stat = "BrandTicks", label = "Activations per Brand", fmt = "d", flag = "brand" }, - { stat = "ManaCost", label = "Mana Cost", fmt = "d", color = colorCodes.MANA, pool = "ManaUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaHasCost end }, - { stat = "ManaPercentCost", label = "Mana Cost", fmt = "d%%", color = colorCodes.MANA, pool = "ManaUnreservedPercent", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaPercentHasCost end }, + { stat = "ManaCostTotal", label = "Mana Cost", fmt = "d", color = colorCodes.MANA, pool = "ManaUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaHasCostTotal end }, + { stat = "ManaPercentCostTotal", label = "Mana Cost", fmt = "d%%", color = colorCodes.MANA, pool = "ManaUnreservedPercent", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaPercentHasCostTotal end }, { stat = "ManaPerSecondCost", label = "Mana Cost per second", fmt = ".2f", color = colorCodes.MANA, pool = "ManaUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaPerSecondHasCost end }, { stat = "ManaPercentPerSecondCost", label = "Mana Cost per second", fmt = ".2f%%", color = colorCodes.MANA, pool = "ManaUnreservedPercent", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaPercentPerSecondHasCost end }, - { stat = "LifeCost", label = "Life Cost", fmt = "d", color = colorCodes.LIFE, pool = "LifeUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.LifeHasCost end }, - { stat = "LifePercentCost", label = "Life Cost", fmt = "d%%", color = colorCodes.LIFE, pool = "LifeUnreservedPercent", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.LifePercentHasCost end }, + { stat = "LifeCostTotal", label = "Life Cost", fmt = "d", color = colorCodes.LIFE, pool = "LifeUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.LifeHasCostTotal end }, + { stat = "LifePercentCostTotal", label = "Life Cost", fmt = "d%%", color = colorCodes.LIFE, pool = "LifeUnreservedPercent", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.LifePercentHasCostTotal end }, { stat = "LifePerSecondCost", label = "Life Cost per second", fmt = ".2f", color = colorCodes.LIFE, pool = "LifeUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.LifePerSecondHasCost end }, { stat = "LifePercentPerSecondCost", label = "Life Cost per second", fmt = ".2f%%", color = colorCodes.LIFE, pool = "LifeUnreservedPercent", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.LifePercentPerSecondHasCost end }, - { stat = "ESCost", label = "Energy Shield Cost", fmt = "d", color = colorCodes.ES, pool = "EnergyShield", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ESHasCost end }, + { stat = "ESCostTotal", label = "Energy Shield Cost", fmt = "d", color = colorCodes.ES, pool = "EnergyShield", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ESHasCostTotal end }, { stat = "ESPerSecondCost", label = "ES Cost per second", fmt = ".2f", color = colorCodes.ES, pool = "EnergyShield", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ESPerSecondHasCost end }, { stat = "ESPercentPerSecondCost", label = "ES Cost per second", fmt = ".2f%%", color = colorCodes.ES, compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ESPercentPerSecondHasCost end }, - { stat = "RageCost", label = "Rage Cost", fmt = "d", color = colorCodes.RAGE, pool = "Rage", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.RageHasCost end }, + { stat = "RageCostTotal", label = "Rage Cost", fmt = "d", color = colorCodes.RAGE, pool = "Rage", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.RageHasCostTotal end }, { stat = "RagePerSecondCost", label = "Rage Cost per second", fmt = ".2f", color = colorCodes.RAGE, pool = "Rage", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.RagePerSecondHasCost end }, { stat = "SoulCost", label = "Soul Cost", fmt = "d", color = colorCodes.RAGE, pool = "Soul", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.SoulHasCost end }, + { stat = "TotalRecallManaCost", label = "Total Recall Mana Cost/s", fmt = ".2f", color = colorCodes.MANA, pool = "ManaUnreserved", compPercent = true, lowerIsBetter = true, flag = "recalled", breakdown = "TotalRecallManaCost", condFunc = function(v,o) return o.TotalRecallManaHasCost end }, + { stat = "TotalRecallManaPercentCost", label = "Total Recall Mana Cost/s", fmt = ".2f%%", color = colorCodes.MANA, pool = "ManaUnreservedPercent", compPercent = true, lowerIsBetter = true, flag = "recalled", breakdown = "TotalRecallManaPercentCost", condFunc = function(v,o) return o.TotalRecallManaPercentHasCost end }, + { stat = "TotalRecallLifeCost", label = "Total Recall Life Cost/s", fmt = ".2f", color = colorCodes.LIFE, pool = "LifeUnreserved", compPercent = true, lowerIsBetter = true, flag = "recalled", breakdown = "TotalRecallLifeCost", condFunc = function(v,o) return o.TotalRecallLifeHasCost end }, + { stat = "TotalRecallLifePercentCost", label = "Total Recall Life Cost/s", fmt = ".2f%%", color = colorCodes.LIFE, pool = "LifeUnreservedPercent", compPercent = true, lowerIsBetter = true, flag = "recalled", breakdown = "TotalRecallLifePercentCost", condFunc = function(v,o) return o.TotalRecallLifePercentHasCost end }, + { stat = "TotalRecallESCost", label = "Total Recall ES Cost/s", fmt = ".2f", color = colorCodes.ES, pool = "EnergyShield", compPercent = true, lowerIsBetter = true, flag = "recalled", breakdown = "TotalRecallESCost", condFunc = function(v,o) return o.TotalRecallESHasCost end }, + { stat = "TotalRecallRageCost", label = "Total Recall Rage Cost/s", fmt = ".2f", color = colorCodes.RAGE, pool = "Rage", compPercent = true, lowerIsBetter = true, flag = "recalled", breakdown = "TotalRecallRageCost", condFunc = function(v,o) return o.TotalRecallRageHasCost end }, { }, { stat = "Str", label = "Strength", color = colorCodes.STRENGTH, fmt = "d", breakdown = "Str" }, { stat = "ReqStr", label = "Strength Required", color = colorCodes.STRENGTH, fmt = "d", lowerIsBetter = true, condFunc = function(v,o) return v > o.Str end, warnFunc = function(v,o) return "You do not meet the Strength requirement of " .. (o.ReqStrItem.source == "Item" and o.ReqStrItem.sourceItem.name or o.ReqStrItem.source == "Gem" and o.ReqStrItem.sourceGem.nameSpec) end }, diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index de16231644e..8ce2c16107c 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -246,6 +246,14 @@ function calcs.buildActiveSkillModList(env, activeSkill) -- Handle multipart skills local activeGemParts = activeGrantedEffect.parts + local recalledParts = calcs.buildRecalledSkillParts(env, activeSkill) + if recalledParts then + activeGemParts = copyTable(activeGemParts or { { name = "Default" } }) + for _, part in ipairs(recalledParts) do + table.insert(activeGemParts, part) + end + end + activeSkill.skillPartList = activeGemParts if activeGemParts and #activeGemParts > 1 then if env.mode == "CALCS" and activeSkill == env.player.mainSkill then activeEffect.srcInstance.skillPartCalcs = m_min(#activeGemParts, activeEffect.srcInstance.skillPartCalcs or 1) @@ -255,6 +263,8 @@ function calcs.buildActiveSkillModList(env, activeSkill) activeSkill.skillPart = activeEffect.srcInstance.skillPart end local part = activeGemParts[activeSkill.skillPart] + -- Recalled variants retain the original part index used by gem modifiers. + activeSkill.skillPart = part.originalPart or activeSkill.skillPart for k, v in pairs(part) do if v == true then skillFlags[k] = true diff --git a/src/Modules/CalcBrandRecall.lua b/src/Modules/CalcBrandRecall.lua new file mode 100644 index 00000000000..2c5bdd4878d --- /dev/null +++ b/src/Modules/CalcBrandRecall.lua @@ -0,0 +1,188 @@ +-- Path of Building +-- Recalled skill variants and activation rates, excluding natural activations. +local calcs = require("Modules.CalcBase") + +local recallCostResources = { + { key = "Mana", label = "mana" }, + { key = "ManaPercent", label = "mana %", percent = true }, + { key = "Life", label = "life" }, + { key = "LifePercent", label = "life %", percent = true }, + { key = "ES", label = "energy shield" }, + { key = "Rage", label = "rage" }, +} + +function calcs.buildRecalledSkillParts(env, activeSkill) + local effect = activeSkill.activeEffect.grantedEffect + local group = activeSkill.socketGroup + if not group or not group.brandRecallId or not activeSkill.activeEffect.srcInstance then + return + end + local eligible = activeSkill.skillTypes[SkillType.Brand] and effect.name ~= "Arcanist Brand" + for _, support in ipairs(activeSkill.effectList) do + if support.grantedEffect.id == "SupportBrandSupport" then + eligible = true + end + end + if not eligible then + return + end + local foundRecall = false + for _, recallGroup in ipairs(env.build.skillsTab.socketGroupList) do + -- Keep the variant available while a Recall setup is disabled, so disabling + -- it yields zero recalled damage rather than silently selecting natural DPS. + if recallGroup.brandRecallSourceId == group.brandRecallId then + for _, gem in ipairs(recallGroup.gemList) do + local grantedEffect = gem.grantedEffect or (gem.gemData and gem.gemData.grantedEffect) + if grantedEffect and grantedEffect.id == "BrandRecall" then + foundRecall = true + end + end + end + end + if not foundRecall then + return + end + local recalledParts = { } + local baseParts = effect.parts or { { name = "Default" } } + for index, basePart in ipairs(baseParts) do + local part = copyTable(basePart) + part.name = effect.parts and (part.name .. " (Recalled)") or "Recalled" + part.originalPart = index + part.recalled = true + part.recalledBrand = activeSkill.skillTypes[SkillType.Brand] + table.insert(recalledParts, part) + end + return recalledParts +end + +function calcs.recalledSkillRate(env, activeSkill) + if not activeSkill.skillFlags.recalled then + return + end + local uuid = cacheSkillUUID(activeSkill, env) + if env.limitedSkills and env.limitedSkills[uuid] then + return + end + local brand = activeSkill + if activeSkill.skillData.triggeredByBrand then + for _, skill in ipairs(env.player.activeSkillList) do + if skill.socketGroup == activeSkill.socketGroup and skill.activeEffect.grantedEffect.name == "Arcanist Brand" then + brand = skill + break + end + end + end + local count = brand.skillModList:Sum("BASE", brand.skillCfg, "ActiveBrandLimit") + local configured = env.build.configTab.input.ActiveBrands + if configured ~= nil then + count = math.min(count, math.max(0, math.floor(configured))) + end + local output = env.player.output + local breakdown = env.player.breakdown + local brandOutput = output + if brand ~= activeSkill then + local brandUUID = cacheSkillUUID(brand, env) + if not GlobalCache.cachedData[env.mode][brandUUID] or env.mode == "CALCULATOR" then + calcs.buildActiveSkill(env, env.mode, brand, brandUUID, { uuid, brandUUID }) + end + local cachedBrand = GlobalCache.cachedData[env.mode][brandUUID] + brandOutput = cachedBrand and cachedBrand.Env.player.output or { } + end + local rotation = 1 + local triggers = 1 + local linkedSpells = { } + if activeSkill.skillData.triggeredByBrand then + triggers = env.player.modDB:Flag(nil, "HaveTriggerBots") and 2 or 1 + rotation = 0 + for _, skill in ipairs(env.player.activeSkillList) do + if skill.socketGroup == activeSkill.socketGroup and skill.skillData.triggeredByBrand and not skill.skillFlags.disable then + rotation = rotation + 1 + local spellOutput = output + if skill ~= activeSkill then + local spellUUID = cacheSkillUUID(skill, env) + if not GlobalCache.cachedData[env.mode][spellUUID] or env.mode == "CALCULATOR" then + calcs.buildActiveSkill(env, env.mode, skill, spellUUID, { uuid, spellUUID }) + end + local cachedSpell = GlobalCache.cachedData[env.mode][spellUUID] + spellOutput = cachedSpell and cachedSpell.Env.player.output or { } + end + table.insert(linkedSpells, spellOutput) + end + end + rotation = math.max(rotation, 1) + end + local lines = { "Natural brand activations are excluded." } + local recallRate = 0 + local recallCosts = { } + local recallCostBreakdowns = { } + if not brand.skillModList:Flag(brand.skillCfg, "Condition:CannotRecallBrand") then + for _, recall in ipairs(env.player.activeSkillList) do + if recall.skillData.brandRecallCostPercent and recall.socketGroup + and recall.socketGroup.brandRecallSourceId == activeSkill.socketGroup.brandRecallId + and recall.socketGroup.enabled and recall.socketGroup.slotEnabled ~= false and not recall.skillFlags.disable then + local recallUUID = cacheSkillUUID(recall, env) + if not (env.limitedSkills and env.limitedSkills[recallUUID]) then + if not GlobalCache.cachedData[env.mode][recallUUID] or env.mode == "CALCULATOR" then + calcs.buildActiveSkill(env, env.mode, recall, recallUUID, { uuid }) + end + local cached = GlobalCache.cachedData[env.mode][recallUUID] + if cached then + local recallOutput = cached.Env.player.output + local rate = recallOutput.Speed + if not rate or rate <= 0 then + rate = recallOutput.Cooldown and recallOutput.Cooldown > 0 and 1 / recallOutput.Cooldown or 0 + end + recallRate = recallRate + rate + local recallName = recall.socketGroup.label ~= "" and recall.socketGroup.label or recall.socketGroup.slot or "Brand Recall" + table.insert(lines, string.format("+ %.3f ^8(Recall activations/s: %s)", rate, recallName)) + for _, resource in ipairs(recallCostResources) do + local costName = resource.key.."Cost" + local cost = recallOutput[costName] or 0 + local brandCost = brandOutput[costName] or 0 + local recalledBrandCost = brandCost * recall.skillData.brandRecallCostPercent / 100 + if not resource.percent then + recalledBrandCost = math.floor(recalledBrandCost) + end + cost = cost + recalledBrandCost * count + for _, spellOutput in ipairs(linkedSpells) do + cost = cost + (spellOutput[costName] or 0) * triggers * count / #linkedSpells + end + if cost > 0 then + local perSecond = cost * rate + recallCosts[resource.key] = (recallCosts[resource.key] or 0) + perSecond + recallCostBreakdowns[resource.key] = recallCostBreakdowns[resource.key] or { } + table.insert(recallCostBreakdowns[resource.key], string.format("+ %g%s x %.3f = %.2f%s ^8(%s cost per use x trigger rate)", cost, resource.percent and "%%" or "", rate, perSecond, resource.percent and "%%" or "", recallName)) + end + end + end + end + end + end + end + local rate = recallRate * count * triggers / rotation + output.RecalledBrandCount = count + output.BrandRecallRate = recallRate + output.RecalledSkillRate = rate + for _, resource in ipairs(recallCostResources) do + local value = recallCosts[resource.key] or 0 + output["TotalRecall"..resource.key.."Cost"] = value + if value > 0 then + output["TotalRecall"..resource.key.."HasCost"] = true + if breakdown then + local costLines = recallCostBreakdowns[resource.key] + table.insert(costLines, string.format("= %.2f%s ^8(total Recall %s cost per second)", value, resource.percent and "%%" or "", resource.label)) + breakdown["TotalRecall"..resource.key.."Cost"] = costLines + end + end + end + if breakdown then + table.insert(lines, string.format("x %d ^8(active brands)", count)) + if activeSkill.skillData.triggeredByBrand then + table.insert(lines, string.format("x %d ^8(linked spell triggers per activation)", triggers)) + table.insert(lines, string.format("/ %d ^8(linked spell rotation)", rotation)) + end + table.insert(lines, string.format("= %.3f ^8(recalled activations per second)", rate)) + breakdown.RecalledSkillRate = lines + end + return rate +end diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 8458c4e468f..b373c1fba69 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -1460,6 +1460,11 @@ function calcs.offence(env, actor, activeSkill) output.BrandAttachmentRangeMetre = output.BrandAttachmentRange / 10 output.ActiveBrandLimit = skillModList:Sum("BASE", skillCfg, "ActiveBrandLimit") output.AttachedBrandCount = skillData.attachedBrandCount + if skillFlags.recalled then + -- Recall's activation rate already includes every active brand. + output.AttachedBrandCount = 1 + skillData.attachedBrandCount = 1 + end if breakdown then breakdown.BrandAttachmentRange = { radius = output.BrandAttachmentRange } end @@ -1894,6 +1899,100 @@ function calcs.offence(env, actor, activeSkill) end end end + -- Recalling spends part of each brand's own cost, in addition to Recall's cost. + -- Apply this after Recall's cost modifiers so its supports and conversions do + -- not modify the cost already calculated for the brand's separate gem group. + local brandSourceId = activeSkill.socketGroup and activeSkill.socketGroup.brandRecallSourceId + local recallUUID = skillData.brandRecallCostPercent and cacheSkillUUID(activeSkill, env) + if brandSourceId and recallUUID and not (env.limitedSkills and env.limitedSkills[recallUUID]) then + for _, brand in ipairs(actor.activeSkillList) do + if brand.skillTypes[SkillType.Brand] and brand.socketGroup and brand.socketGroup.brandRecallId == brandSourceId + and brand.socketGroup.enabled and brand.socketGroup.slotEnabled ~= false and not brand.skillFlags.disable + and not brand.skillModList:Flag(brand.skillCfg, "Condition:CannotRecallBrand") then + local brandUUID = cacheSkillUUID(brand, env) + if not GlobalCache.cachedData[env.mode][brandUUID] or env.mode == "CALCULATOR" then + calcs.buildActiveSkill(env, env.mode, brand, brandUUID, { recallUUID, brandUUID }) + end + local cached = GlobalCache.cachedData[env.mode][brandUUID] + if cached then + local brandOutput = cached.Env.player.output + local count = brandOutput.ActiveBrandLimit or 0 + local configuredBrands = env.build.configTab.input.ActiveBrands + if configuredBrands ~= nil then + count = m_min(count, m_max(0, m_floor(configuredBrands))) + end + local linkedSpells = { } + if brand.activeEffect.grantedEffect.name == "Arcanist Brand" then + for _, spell in ipairs(actor.activeSkillList) do + if spell.socketGroup == brand.socketGroup and spell.skillData.triggeredByBrand and not spell.skillFlags.disable and spell ~= activeSkill then + local spellUUID = cacheSkillUUID(spell, env) + if not (env.limitedSkills and env.limitedSkills[spellUUID]) then + if not GlobalCache.cachedData[env.mode][spellUUID] or env.mode == "CALCULATOR" then + calcs.buildActiveSkill(env, env.mode, spell, spellUUID, { recallUUID, brandUUID, spellUUID }) + end + local spellCache = GlobalCache.cachedData[env.mode][spellUUID] + if spellCache then + t_insert(linkedSpells, { name = spell.activeEffect.grantedEffect.name, output = spellCache.Env.player.output }) + end + end + end + end + end + -- One linked spell triggers per brand activation. Average over a full + -- rotation if several spells are linked. Recall's own duplication is + -- already included in its trigger rate; only duplicate the child here. + local spellTriggers = actor.modDB:Flag(nil, "HaveTriggerBots") and 2 or 1 + for _, resource in ipairs(costs.order) do + local val = costs[resource] + if val.upfront and resource ~= "Soul" then + local costName = resource.."Cost" + local brandCost = brandOutput[costName] or 0 + local perBrand = brandCost * skillData.brandRecallCostPercent / 100 + if not val.percent then + perBrand = m_floor(perBrand) + end + local addedCost = perBrand * count + local linkedCost = 0 + for _, spell in ipairs(linkedSpells) do + linkedCost = linkedCost + (spell.output[costName] or 0) * spellTriggers * count / #linkedSpells + end + addedCost = addedCost + linkedCost + if addedCost > 0 then + local recallCost = output[costName] or 0 + -- Keep the individual upfront cost intact for cost-based mechanics. + output[costName.."Total"] = recallCost + addedCost + output[resource.."HasCostTotal"] = true + if breakdown then + local totalCostName = costName.."Total" + breakdown[totalCostName] = copyTable(breakdown[costName] or { s_format("%g ^8(Brand Recall upfront cost)", recallCost) }) + t_insert(breakdown[totalCostName], s_format("+ %g x %d ^8(%d%% of %s's %g %s cost per brand)", perBrand, count, skillData.brandRecallCostPercent, brand.activeEffect.grantedEffect.name, brandCost, val.text)) + for _, spell in ipairs(linkedSpells) do + if (spell.output[costName] or 0) > 0 then + t_insert(breakdown[totalCostName], s_format("+ %g x %d x %d / %d ^8(%s cost x active brands x triggers per activation / linked spell rotation)", spell.output[costName], count, spellTriggers, #linkedSpells, spell.name)) + end + end + t_insert(breakdown[totalCostName], s_format("= %g", output[totalCostName])) + end + end + end + end + end + break + end + end + end + -- Display and resource consumption use the total spending caused by a use. + -- For ordinary skills this is simply their own upfront cost. + for _, resource in ipairs(costs.order) do + if costs[resource].upfront then + local costName = resource.."Cost" + output[costName.."Total"] = output[costName.."Total"] or output[costName] or 0 + output[resource.."HasCostTotal"] = output[resource.."HasCostTotal"] or output[resource.."HasCost"] + if breakdown then + breakdown[costName.."Total"] = breakdown[costName.."Total"] or breakdown[costName] + end + end + end -- Eldritch Battery adds maximum Energy Shield to the Mana available for payable-cost damage. output.ManaCostPayablePool = (output.ManaUnreserved or 0) + (modDB:Flag(nil, "EnergyShieldProtectsMana") and output.EnergyShield or 0) @@ -2486,6 +2585,29 @@ function calcs.offence(env, actor, activeSkill) end end end + local recalledRate = actor == env.player and calcs.recalledSkillRate(env, activeSkill) + if recalledRate ~= nil and recalledRate ~= false then + local rate = recalledRate + -- Preserve the selected skill's damage modifiers and hit-pattern multiplier, + -- but replace natural activations with activations caused by Recall alone. + if activeSkill.skillTypes[SkillType.Brand] then + output.AttachedBrandCount = 1 + output.BrandTicks = nil + output.HitSpeed = rate + output.HitTime = rate > 0 and 1 / rate or math.huge + skillData.hitTimeOverride = output.HitTime + else + local hitsPerActivation = output.HitSpeed and output.Speed > 0 and output.HitSpeed / output.Speed or 1 + output.Speed = rate + output.Time = rate > 0 and 1 / rate or math.huge + output.SkillTriggerRate = rate + output.HitSpeed = rate * hitsPerActivation + output.HitTime = output.HitSpeed > 0 and 1 / output.HitSpeed or math.huge + end + skillData.showAverage = false + skillFlags.showAverage = false + skillFlags.notAverage = true + end -- Other Misc DPS multipliers (like custom source) skillData.dpsMultiplier = ( skillData.dpsMultiplier or 1 ) * ( 1 + skillModList:Sum("INC", skillCfg, "DPS") / 100 ) * skillModList:More(skillCfg, "DPS") if activeSkill.skillTypes[SkillType.Brand] and not skillData.countsAttachedBrandsInDamage then @@ -2596,6 +2718,16 @@ function calcs.offence(env, actor, activeSkill) end end + if breakdown and recalledRate ~= nil and recalledRate ~= false then + breakdown.HitSpeed = copyTable(breakdown.RecalledSkillRate) + if output.HitSpeed ~= recalledRate then + t_insert(breakdown.HitSpeed, s_format("= %.3f ^8(hits per second after the skill's hit-pattern multiplier)", output.HitSpeed)) + end + if skillData.triggeredByBrand then + breakdown.SkillTriggerRate = copyTable(breakdown.RecalledSkillRate) + end + end + -- Grab quantity multiplier local quantityMultiplier = m_max(activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "QuantityMultiplier"), 1) if quantityMultiplier > 1 then @@ -5917,7 +6049,8 @@ function calcs.offence(env, actor, activeSkill) for _, resource in ipairs(costs.order) do local val = costs[resource] local EB = env.modDB:Flag(nil, "EnergyShieldProtectsMana") - if(val.upfront and output[resource.."HasCost"] and output[resource.."Cost"] > 0 and not (output[resource.."PerSecondHasCost"] and not (EB and skillModList:Sum("BASE", skillCfg, "ManaCostAsEnergyShieldCost"))) and (output.Speed > 0 or output.Cooldown)) then + local totalCost = output[resource.."CostTotal"] or 0 + if(val.upfront and output[resource.."HasCostTotal"] and totalCost > 0 and not (output[resource.."PerSecondHasCost"] and not (EB and skillModList:Sum("BASE", skillCfg, "ManaCostAsEnergyShieldCost"))) and (output.Speed > 0 or output.Cooldown)) then local usedResource = resource if EB and resource == "Mana" then @@ -5945,10 +6078,10 @@ function calcs.offence(env, actor, activeSkill) end output[usedResource.."PerSecondHasCost"] = true - output[usedResource.."PerSecondCost"] = (output[usedResource.."PerSecondCost"] or 0)+ output[resource.."Cost"] * useSpeed + output[usedResource.."PerSecondCost"] = (output[usedResource.."PerSecondCost"] or 0)+ totalCost * useSpeed if breakdown then - breakdown[usedResource.."PerSecondCost"] = copyTable(breakdown[resource.."Cost"]) + breakdown[usedResource.."PerSecondCost"] = copyTable(breakdown[resource.."CostTotal"]) t_remove(breakdown[usedResource.."PerSecondCost"]) t_insert(breakdown[usedResource.."PerSecondCost"], s_format("x %.2f ^8("..timeType.." speed)", useSpeed)) t_insert(breakdown[usedResource.."PerSecondCost"], s_format("= %.2f per second", output[usedResource.."PerSecondCost"])) @@ -6260,4 +6393,14 @@ function calcs.offence(env, actor, activeSkill) output.CullingDPS = output.CombinedDPS * (bestCull - 1) output.ReservationDPS = output.CombinedDPS * (output.ReservationDpsMultiplier - 1) output.CombinedDPS = output.CombinedDPS * bestCull * output.ReservationDpsMultiplier + if recalledRate == 0 then + -- No Recall activations can apply hits, ailments or persistent damage. + -- Keep per-hit damage available, but exclude every DPS component. + for stat, value in pairs(output) do + if type(value) == "number" and stat:match("DPS$") then + output[stat] = 0 + end + end + output.TotalDot = 0 + end end diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index c7f75eda6c4..36ac205d203 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -1353,6 +1353,16 @@ function calcs.perform(env, skipEHP) end applyEnemyModifiers(env.enemy, true) local minionCounts = { } + local recalledBrandsUseMaximum = env.build.configTab.input.ActiveBrands == nil + if recalledBrandsUseMaximum then + recalledBrandsUseMaximum = false + for _, activeSkill in ipairs(env.player.activeSkillList) do + if activeSkill.skillFlags.recalled then + recalledBrandsUseMaximum = true + break + end + end + end for _, activeSkill in ipairs(env.player.activeSkillList) do if activeSkill.skillTypes[SkillType.Brand] then @@ -1360,10 +1370,11 @@ function calcs.perform(env, skipEHP) local configured = modDB:Sum("BASE", nil, "Multiplier:ConfigBrandsAttachedToEnemy") local attached = configured > 0 and m_min(configured, attachLimit) or attachLimit activeSkill.skillData.attachedBrandCount = attached - local activeBrands = modDB:Sum("BASE", nil, "Multiplier:ConfigActiveBrands") + local activeBrandLimit = activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "ActiveBrandLimit") + local activeBrands = recalledBrandsUseMaximum and activeBrandLimit or modDB:Sum("BASE", nil, "Multiplier:ConfigActiveBrands") -- Cap the number of active brands by the limit, which is 3 by default -- Also consider increase to number of active brands from other sources (e.g. Foulgrasp Support) - modDB.multipliers["ActiveBrand"] = m_max(m_min(activeBrands, activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "ActiveBrandLimit")), modDB.multipliers["ActiveBrand"] or 0) + modDB.multipliers["ActiveBrand"] = m_max(m_min(activeBrands, activeBrandLimit), modDB.multipliers["ActiveBrand"] or 0) modDB.multipliers["BrandsAttachedToEnemy"] = m_max(attached, modDB.multipliers["BrandsAttachedToEnemy"] or 0) enemyDB.multipliers["BrandsAttached"] = m_max(attached, enemyDB.multipliers["BrandsAttached"] or 0) end diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index e6b2284aa57..4eb86057ce7 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -595,7 +595,7 @@ return { { label = "Cast time", flag = "spell", notFlag = "triggered", { format = "{2:output:Time}s", }, }, { label = "CWDT Threshold", haveOutput = "CWDTThreshold", flag = "triggered", { format = "{2:output:CWDTThreshold}", { breakdown = "CWDTThreshold" }, }, }, { label = "Channel time", flag = "channelRelease", haveOutput = "HitTime", { format = "{2:output:HitTime}s", { breakdown = "HitTime" } }, }, - { label = "Hit Rate", haveOutput = "HitSpeed", { format = "{2:output:HitSpeed}", { breakdown = "HitSpeed" } }, }, + { label = "Hit Rate", haveOutput = "HitSpeed", notFlag = "recalledBrand", { format = "{2:output:HitSpeed}", { breakdown = "HitSpeed" } }, }, { label = "Inc. Warcry Speed", flag = "warcry", { format = "{0:mod:2}%", { breakdown = "WarcrySpeed" }, { modName = "WarcrySpeed", modType = "INC", cfg = "skill", }, }, }, { label = "More Warcry Speed", flag = "warcry", { format = "{0:mod:2}%", { breakdown = "WarcrySpeed" }, { modName = "WarcrySpeed", modType = "MORE", cfg = "skill", }, }, }, { label = "Uses per second", flag = "warcry", {format = "{2:output:Speed}"}, }, @@ -712,20 +712,26 @@ return { { 1, "SkillTypeStats", 1, colorCodes.OFFENCE, {{ defaultCollapsed = false, label = "Skill type-specific Stats", data = { { label = "Gem Level", haveOutput = "GemHasLevel", { format = "{0:output:GemLevel}", { breakdown = "GemLevel" }, { modName = { "GemLevel" }, cfg = "skill" },{ modName = { "GemSupportLevel" }, cfg = "skill" }, { modName = { "GemItemLevel" }, cfg = "skill" }, }, }, { label = "Gem Quality", haveOutput = "GemHasQuality", { format = "{0:output:GemQuality}", { breakdown = "GemQuality" }, { modName = { "GemQuality", "GemSupportQuality", "GemItemQuality", "GemSocketQuality" }, cfg = "skill" }, }, }, - { label = "Mana Cost", color = colorCodes.MANA, haveOutput = "ManaHasCost", { format = "{0:output:ManaCost}", { breakdown = "ManaCost" }, { modName = manaCost, cfg = "skill" }, }, }, - { label = "Mana % Cost", color = colorCodes.MANA, haveOutput = "ManaPercentHasCost", { format = "{0:output:ManaPercentCost}", { breakdown = "ManaPercentCost" }, { modName = manaCost, cfg = "skill" }, }, }, + { label = "Mana Cost", color = colorCodes.MANA, haveOutput = "ManaHasCostTotal", { format = "{0:output:ManaCostTotal}", { breakdown = "ManaCostTotal" }, { modName = manaCost, cfg = "skill" }, }, }, + { label = "Mana % Cost", color = colorCodes.MANA, haveOutput = "ManaPercentHasCostTotal", { format = "{0:output:ManaPercentCostTotal}", { breakdown = "ManaPercentCostTotal" }, { modName = manaCost, cfg = "skill" }, }, }, { label = "Mana per second", color = colorCodes.MANA, haveOutput = "ManaPerSecondHasCost", { format = "{2:output:ManaPerSecondCost}", { breakdown = "ManaPerSecondCost" }, { modName = manaCost, cfg = "skill" }, }, }, { label = "Mana % per second", color = colorCodes.MANA, haveOutput = "ManaPercentPerSecondHasCost", { format = "{2:output:ManaPercentPerSecondCost}", { breakdown = "ManaPercentPerSecondCost" }, { modName = manaCost, cfg = "skill" }, }, }, - { label = "Life Cost", color = colorCodes.LIFE, haveOutput = "LifeHasCost", { format = "{0:output:LifeCost}", { breakdown = "LifeCost" }, { modName = lifeCost, cfg = "skill" }, }, }, - { label = "Life % Cost", color = colorCodes.LIFE, haveOutput = "LifePercentHasCost", { format = "{0:output:LifePercentCost}", { breakdown = "LifePercentCost" }, { modName = lifeCost, cfg = "skill" }, }, }, + { label = "Life Cost", color = colorCodes.LIFE, haveOutput = "LifeHasCostTotal", { format = "{0:output:LifeCostTotal}", { breakdown = "LifeCostTotal" }, { modName = lifeCost, cfg = "skill" }, }, }, + { label = "Life % Cost", color = colorCodes.LIFE, haveOutput = "LifePercentHasCostTotal", { format = "{0:output:LifePercentCostTotal}", { breakdown = "LifePercentCostTotal" }, { modName = lifeCost, cfg = "skill" }, }, }, { label = "Life per second", color = colorCodes.LIFE, haveOutput = "LifePerSecondHasCost", { format = "{2:output:LifePerSecondCost}", { breakdown = "LifePerSecondCost" }, { modName = lifeCost, cfg = "skill" }, }, }, { label = "Life % per second", color = colorCodes.LIFE, haveOutput = "LifePercentPerSecondHasCost", { format = "{2:output:LifePercentPerSecondCost}", { breakdown = "LifePercentPerSecondCost" }, { modName = lifeCost, cfg = "skill" }, }, }, - { label = "ES Cost", color = colorCodes.ES, haveOutput = "ESHasCost", { format = "{0:output:ESCost}", { breakdown = "ESCost" }, { modName = ESCost, cfg = "skill" }, }, }, + { label = "ES Cost", color = colorCodes.ES, haveOutput = "ESHasCostTotal", { format = "{0:output:ESCostTotal}", { breakdown = "ESCostTotal" }, { modName = ESCost, cfg = "skill" }, }, }, { label = "ES per second", color = colorCodes.ES, haveOutput = "ESPerSecondHasCost", { format = "{2:output:ESPerSecondCost}", { breakdown = "ESPerSecondCost" }, { modName = ESCost, cfg = "skill" }, }, }, { label = "ES % per second", color = colorCodes.ES, haveOutput = "ESPercentPerSecondHasCost", { format = "{2:output:ESPercentPerSecondCost}", { breakdown = "ESPercentPerSecondCost" }, { modName = ESCost, cfg = "skill" }, }, }, - { label = "Rage Cost", color = colorCodes.RAGE, haveOutput = "RageHasCost", { format = "{0:output:RageCost}", { breakdown = "RageCost" }, { modName = rageCost, cfg = "skill" }, }, }, + { label = "Rage Cost", color = colorCodes.RAGE, haveOutput = "RageHasCostTotal", { format = "{0:output:RageCostTotal}", { breakdown = "RageCostTotal" }, { modName = rageCost, cfg = "skill" }, }, }, { label = "Rage per second", color = colorCodes.RAGE, haveOutput = "RagePerSecondHasCost", { format = "{2:output:RagePerSecondCost}", { breakdown = "RagePerSecondCost" }, { modName = rageCost, cfg = "skill" }, }, }, { label = "Soul Cost", color = colorCodes.RAGE, haveOutput = "SoulHasCost", { format = "{0:output:SoulCost}", { breakdown = "SoulCost" }, { modName = { "SoulCost", "SoulCostEfficiency" }, cfg = "skill" }, }, }, + { label = "Total Recall Mana Cost/s", color = colorCodes.MANA, flag = "recalled", haveOutput = "TotalRecallManaHasCost", { format = "{2:output:TotalRecallManaCost}", { breakdown = "TotalRecallManaCost" }, }, }, + { label = "Total Recall Mana % Cost/s", color = colorCodes.MANA, flag = "recalled", haveOutput = "TotalRecallManaPercentHasCost", { format = "{2:output:TotalRecallManaPercentCost}%", { breakdown = "TotalRecallManaPercentCost" }, }, }, + { label = "Recall Life Cost/s", color = colorCodes.LIFE, flag = "recalled", haveOutput = "TotalRecallLifeHasCost", { format = "{2:output:TotalRecallLifeCost}", { breakdown = "TotalRecallLifeCost" }, }, }, + { label = "Total Recall Life % Cost/s", color = colorCodes.LIFE, flag = "recalled", haveOutput = "TotalRecallLifePercentHasCost", { format = "{2:output:TotalRecallLifePercentCost}%", { breakdown = "TotalRecallLifePercentCost" }, }, }, + { label = "Total Recall ES Cost/s", color = colorCodes.ES, flag = "recalled", haveOutput = "TotalRecallESHasCost", { format = "{2:output:TotalRecallESCost}", { breakdown = "TotalRecallESCost" }, }, }, + { label = "Total Recall Rage Cost/s", color = colorCodes.RAGE, flag = "recalled", haveOutput = "TotalRecallRageHasCost", { format = "{2:output:TotalRecallRageCost}", { breakdown = "TotalRecallRageCost" }, }, }, { label = "Active Minion Limit", haveOutput = "ActiveMinionLimit", { format = "{0:output:ActiveMinionLimit}" } }, { label = "Quantity Multiplier", haveOutput = "QuantityMultiplier", { format = "{0:output:QuantityMultiplier}", { breakdown = "QuantityMultiplier" }, @@ -909,6 +915,8 @@ return { { modName = "TotemArmour", cfg = "skill" }, }, }, { label = "Active Brand Limit", haveOutput = "ActiveBrandLimit", { format = "{0:output:ActiveBrandLimit}", { modName = "ActiveBrandLimit", cfg = "skill" }, }, }, + { label = "Recalled Brands", flag = "recalled", { format = "{0:output:RecalledBrandCount}" }, }, + { label = "Recall Rate", flag = "recalled", { format = "{2:output:RecalledSkillRate}", { breakdown = "RecalledSkillRate" } }, }, { label = "Totem Fire Res", flag = "totem", notFlag = "triggered",{ format = "{0:output:TotemFireResist}% (+{0:output:TotemFireResistOverCap}%)", { breakdown = "TotemFireResist" }, { modName = { "TotemFireResistMax", "TotemElementalResistMax", "TotemFireResist", "TotemElementalResist" }, }, diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index 777890cacb2..2cc5416e6ab 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -66,13 +66,14 @@ local function packageSkillDataForSimulation(skill, env) return { uuid = cacheSkillUUID(skill, env), cd = skill.skillData.cooldown, cdOverride = skill.skillModList:Override(skill.skillCfg, "CooldownRecovery"), addsCastTime = processAddedCastTime(skill), icdr = calcLib.mod(skill.skillModList, skill.skillCfg, "CooldownRecovery"), addedCooldown = skill.skillModList:Sum("BASE", skill.skillCfg, "CooldownRecovery")} end -local function defaultComparer(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed +local function defaultComparer(env, uuid, source, triggerRate, useCastRate) + local cached = GlobalCache.cachedData[env.mode][uuid] + local cachedSpeed = useCastRate and cached.Speed or cached.HitSpeed or cached.Speed return (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) end -- Identify the trigger action skill for trigger conditions, take highest Attack Per Second -local function findTriggerSkill(env, skill, source, triggerRate, comparer) +local function findTriggerSkill(env, skill, source, triggerRate, comparer, useCastRate) local comparer = comparer or defaultComparer local uuid = cacheSkillUUID(skill, env) @@ -80,8 +81,9 @@ local function findTriggerSkill(env, skill, source, triggerRate, comparer) calcs.buildActiveSkill(env, env.mode, skill, uuid) end - if GlobalCache.cachedData[env.mode][uuid] and comparer(env, uuid, source, triggerRate) and (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] then - return skill, GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed, uuid + if GlobalCache.cachedData[env.mode][uuid] and comparer(env, uuid, source, triggerRate, useCastRate) and (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] then + local cached = GlobalCache.cachedData[env.mode][uuid] + return skill, useCastRate and cached.Speed or cached.HitSpeed or cached.Speed, uuid end return source, triggerRate, source and cacheSkillUUID(source, env) end @@ -584,10 +586,15 @@ local function defaultTriggerHandler(env, config) local actionCooldown = cooldownOverride or m_max((triggerCD or 0) + (output.addsCastTime or 0), (triggeredCD or 0) + (addedCooldown or 0)) local actionCooldownAdjusted = cooldownOverride or m_max(triggerCDAdjusted, triggeredCDAdjusted) local actionCooldownTickRounded = cooldownOverride and (m_ceil(cooldownOverride * data.misc.ServerTickRate) / data.misc.ServerTickRate) or m_max(triggerCDTickRounded, triggeredCDTickRounded) + -- Brand activation frequency is represented as a trigger cooldown here, + -- but it is not a cooldown that the linked spell consumes. + local cooldownNotConsumedChance = actionCooldownTickRounded ~= 0 and not actor.mainSkill.skillData.triggeredByBrand and m_min(m_max(actor.mainSkill.skillModList:Sum("BASE", actor.mainSkill.skillCfg, "CooldownNotConsumedChance"), 0), 100) or 0 + local cooldownConsumedChance = 1 - cooldownNotConsumedChance / 100 + local cooldownNotConsumedMultiplier = cooldownConsumedChance > 0 and 1 / cooldownConsumedChance or m_huge output.TriggerRateCap = source == actor.mainSkill and actor.mainSkill.skillData.triggerRateCapOverride or m_huge if actionCooldownTickRounded ~= 0 then - output.TriggerRateCap = 1 / actionCooldownTickRounded + output.TriggerRateCap = 1 / actionCooldownTickRounded * cooldownNotConsumedMultiplier end if config.triggerName == "Doom Blast" and env.build.configTab.input["doomBlastSource"] == "expiration" then local expirationRate = 1 / GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration @@ -698,7 +705,10 @@ local function defaultTriggerHandler(env, config) else t_insert(breakdown.TriggerRateCap, "Trigger rate:") t_insert(breakdown.TriggerRateCap, s_format("1 / %.3f", actionCooldownTickRounded)) - t_insert(breakdown.TriggerRateCap, s_format("= %.2f ^8per second", output.TriggerRateCap)) + if cooldownNotConsumedChance > 0 then + t_insert(breakdown.TriggerRateCap, s_format("/ %.2f ^8(%g%% chance to consume a cooldown)", cooldownConsumedChance, 100 - cooldownNotConsumedChance)) + end + t_insert(breakdown.TriggerRateCap, s_format("= %.2f ^8%s", output.TriggerRateCap, cooldownNotConsumedChance > 0 and "expected uses per second" or "per second")) end end @@ -796,22 +806,36 @@ local function defaultTriggerHandler(env, config) -- If the current triggered skill ignores tick rate and is the only triggered skill by this trigger use charge based calcs if actor.mainSkill.skillData.ignoresTickRate and ( not config.triggeredSkillCond or (triggeredSkills and #triggeredSkills == 1 and triggeredSkills[1] == packageSkillDataForSimulation(actor.mainSkill, env)) ) then local overlaps = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or config.overlaps - output.SkillTriggerRate = m_min(output.TriggerRateCap, output.EffectiveSourceRate * (overlaps or 1)) + local sourceRate = output.EffectiveSourceRate * (overlaps or 1) + local baseTriggerRate = m_min(output.TriggerRateCap / cooldownNotConsumedMultiplier, sourceRate) + output.SkillTriggerRate = cooldownNotConsumedChance > 0 and 1 / (cooldownConsumedChance / baseTriggerRate + (1 - cooldownConsumedChance) / sourceRate) or baseTriggerRate if breakdown then - if overlaps then - breakdown.SkillTriggerRate = { - s_format("min(%.2f, %.2f * %d) ^8(%d overlaps)", output.TriggerRateCap, output.EffectiveSourceRate, overlaps, overlaps) - } - else - breakdown.SkillTriggerRate = { - s_format("min(%.2f, %.2f)", output.TriggerRateCap, output.EffectiveSourceRate) - } + breakdown.SkillTriggerRate = { s_format("%.2f ^8(rate before cooldown preservation)", baseTriggerRate) } + if cooldownNotConsumedChance > 0 then + t_insert(breakdown.SkillTriggerRate, s_format("x %.3f ^8(expected effect of %g%% chance to not consume a cooldown)", output.SkillTriggerRate / baseTriggerRate, cooldownNotConsumedChance)) end + t_insert(breakdown.SkillTriggerRate, s_format("= %.2f ^8per second", output.SkillTriggerRate)) end elseif actor.mainSkill.skillFlags.globalTrigger and not config.triggeredSkillCond then -- Trigger does not use source rate breakpoints for one reason or another output.SkillTriggerRate = output.EffectiveSourceRate + -- Cooldown-driven triggers such as Automation also trigger once per bot. + if actor.modDB:Flag(nil, "HaveTriggerBots") and actor.mainSkill.skillTypes[SkillType.Spell] then + output.SkillTriggerRate = 2 * output.SkillTriggerRate + if breakdown then + breakdown.SkillTriggerRate = { + s_format("%.2f ^8(Effective source rate)", output.EffectiveSourceRate), + "x 2 ^8(Trigger bots effectively cause the skill to trigger twice)", + s_format("= %.2f ^8per second", output.SkillTriggerRate), + } + end + end else -- Triggers like Cast on Crit go through simulation to calculate the trigger rate of each skill in the trigger group output.SkillTriggerRate, simBreakdown = calcMultiSpellRotationImpact(env, config.triggeredSkillCond and triggeredSkills or {packageSkillDataForSimulation(actor.mainSkill, env)}, output.EffectiveSourceRate, (not actor.mainSkill.skillData.triggeredByBrand and ( triggerCD or triggeredCD ) or 0), triggerChance, actor) + local simulatedTriggerRate = output.SkillTriggerRate + if cooldownNotConsumedChance > 0 and simulatedTriggerRate > 0 then + output.SkillTriggerRate = 1 / (cooldownConsumedChance / simulatedTriggerRate + (1 - cooldownConsumedChance) / output.EffectiveSourceRate) + end + local cooldownPreservationMultiplier = simulatedTriggerRate > 0 and output.SkillTriggerRate / simulatedTriggerRate or 1 local triggerBotsEffective = actor.modDB:Flag(nil, "HaveTriggerBots") and actor.mainSkill.skillTypes[SkillType.Spell] if triggerBotsEffective then output.SkillTriggerRate = 2 * output.SkillTriggerRate @@ -823,9 +847,12 @@ local function defaultTriggerHandler(env, config) if breakdown then breakdown.SkillTriggerRate = { s_format("%.2f ^8(%s)", output.EffectiveSourceRate, (actor.mainSkill.skillData.triggeredByBrand and s_format("%s activations per second", source.activeEffect.grantedEffect.name)) or (not trigRate and s_format("%s triggers per second", skillName)) or "Effective source rate"), - s_format("/ %.2f ^8(Estimated impact of skill rotation, cooldown alignment and trigger chance)", m_max(output.EffectiveSourceRate / output.SkillTriggerRate, 1)), + s_format("/ %.2f ^8(Estimated impact of skill rotation, cooldown alignment and trigger chance)", m_max(output.EffectiveSourceRate / simulatedTriggerRate, 1)), s_format("= %.2f ^8per second", output.SkillTriggerRate), } + if cooldownNotConsumedChance > 0 then + t_insert(breakdown.SkillTriggerRate, 3, s_format("x %.3f ^8(expected effect of %g%% chance to not consume a cooldown)", cooldownPreservationMultiplier, cooldownNotConsumedChance)) + end if triggerBotsEffective then t_insert(breakdown.SkillTriggerRate, 3, "x 2 ^8(Trigger bots effectively cause the skill to trigger twice)") end @@ -1066,7 +1093,9 @@ local configTable = { triggeredSkills = {} for _, skill in ipairs(env.player.activeSkillList) do if (skill.skillTypes[SkillType.Damage] or skill.skillTypes[SkillType.Attack] or skill.skillTypes[SkillType.Spell]) and not skill.skillFlags.aura and skill ~= env.player.mainSkill and not skill.skillData.triggeredByCraft and not skill.activeEffect.grantedEffect.fromItem and not isTriggered(skill) then - source, trigRate, uuid = findTriggerSkill(env, skill, source, trigRate) + -- The craft triggers on skill use; brand activations (including + -- recalled hits) do not count as additional casts. + source, trigRate, uuid = findTriggerSkill(env, skill, source, trigRate, nil, true) if skill.skillFlags and (skill.skillFlags.totem or skill.skillFlags.golem or skill.skillFlags.banner or skill.skillFlags.ballista) and skill.activeEffect.grantedEffect.castTime then if skill.activeEffect.grantedEffect.levels ~= nil then trigRate = 1 / (skill.activeEffect.grantedEffect.castTime + (skill.activeEffect.grantedEffect.levels[skill.activeEffect.level].cooldown or 0)) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index dc12041374f..b2c2f76042f 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -18,6 +18,7 @@ require("Modules.CalcPerform") require("Modules.CalcActiveSkill") require("Modules.CalcDefence") require("Modules.CalcOffence") +require("Modules.CalcBrandRecall") require("Modules.CalcTriggers") require("Modules.CalcMirages") @@ -168,6 +169,10 @@ function calcs.calcFullDPS(build, mode, override, specEnv) for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = getActiveSkillCount(activeSkill) + if activeSkill.skillFlags.recalled and activeSkill.skillTypes[SkillType.Brand] then + -- Recalled brand count is already included in the calculated damage. + activeSkillCount = 1 + end if enabled then fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) @@ -406,8 +411,16 @@ function calcs.buildOutput(build, mode) output.EnergyShieldProtectsMana = env.modDB:Flag(nil, "EnergyShieldProtectsMana") for pool, costResource in pairs({["LifeUnreserved"] = "LifeCost", ["ManaUnreserved"] = "ManaCost", ["Rage"] = "RageCost", ["EnergyShield"] = "ESCost"}) do local cachedCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource] + local chainCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource.."Total"] + local totalPool = (output.EnergyShieldProtectsMana and costResource == "ManaCost" and output["EnergyShield"] or 0) + (output[pool] or 0) + if chainCost and chainCost > (cachedCost or 0) and chainCost > totalPool then + if env.player.mainSkill and cacheSkillUUID(env.player.mainSkill, env) == uuid then + output[costResource.."TotalWarning"] = true + end + output[costResource.."ChainWarningList"] = output[costResource.."ChainWarningList"] or {} + t_insert(output[costResource.."ChainWarningList"], skill.activeEffect.grantedEffect.name) + end if cachedCost then - local totalPool = (output.EnergyShieldProtectsMana and costResource == "ManaCost" and output["EnergyShield"] or 0) + (output[pool] or 0) if totalPool < cachedCost then local rawPool = pool:gsub("Unreserved$", "") local reservation = GlobalCache.cachedData[mode][uuid].Env.player.mainSkill and GlobalCache.cachedData[mode][uuid].Env.player.mainSkill.skillData[rawPool .. "ReservedPercent"] @@ -415,6 +428,7 @@ function calcs.buildOutput(build, mode) if not reservation or (reservation and (totalPool + m_ceil((output[rawPool] or 0) * reservation / 100)) < cachedCost) then if env.player.mainSkill and env.player.mainSkill.activeEffect.grantedEffect.name == skill.activeEffect.grantedEffect.name then output[costResource.."Warning"] = true + output[costResource.."TotalWarning"] = true end output[costResource.."WarningList"] = output[costResource.."WarningList"] or {} t_insert(output[costResource.."WarningList"], skill.activeEffect.grantedEffect.name) @@ -424,6 +438,11 @@ function calcs.buildOutput(build, mode) end for pool, costResource in pairs({["LifeUnreservedPercent"] = "LifePercentCost", ["ManaUnreservedPercent"] = "ManaPercentCost"}) do local cachedCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource] + local chainCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource.."Total"] + if chainCost and chainCost > (cachedCost or 0) and chainCost > (output[pool] or 0) then + output[costResource.."ChainWarningList"] = output[costResource.."ChainWarningList"] or {} + t_insert(output[costResource.."ChainWarningList"], skill.activeEffect.grantedEffect.name) + end if cachedCost then if (output[pool] or 0) < cachedCost then output[costResource.."PercentCostWarningList"] = output[costResource.."PercentCostWarningList"] or {} diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 8ff3e6f8c79..550d7ab13f5 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -328,7 +328,7 @@ return { modList:NewMod("Condition:BloodSacramentReservationEHP", "FLAG", true, "Config") end }, { label = "Brand Skills:", ifSkill = { "Armageddon Brand", "Storm Brand", "Arcanist Brand", "Penance Brand", "Wintertide Brand" }, includeTransfigured = true }, -- I barely resisted the temptation to label this "Generic Brand:" - { var = "ActiveBrands", type = "count", label = "# of active Brands:", ifSkill = { "Armageddon Brand", "Storm Brand", "Arcanist Brand", "Penance Brand", "Wintertide Brand" }, includeTransfigured = true , apply = function(val, modList, enemyModList) + { var = "ActiveBrands", type = "count", label = "# of active Brands (if not maximum):", ifSkill = { "Armageddon Brand", "Storm Brand", "Arcanist Brand", "Penance Brand", "Wintertide Brand" }, includeTransfigured = true , apply = function(val, modList, enemyModList) modList:NewMod("Multiplier:ConfigActiveBrands", "BASE", val, "Config") end }, { var = "BrandsAttachedToEnemy", type = "count", label = "# of Brands attached to enemy (if not maximum):", ifSkillFlag = "brand", apply = function(val, modList, enemyModList) diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index bc1f7b97366..a9e1ef732e7 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -5700,6 +5700,8 @@ local specialModList = { } end, ["focus has (%d+)%% increased cooldown recovery rate"] = function(num) return { mod("FocusCooldownRecovery", "INC", num, { type = "Condition", var = "Focused" }) } end, ["focus has (%d+)%% reduced cooldown recovery rate"] = function(num) return { mod("FocusCooldownRecovery", "INC", -num, { type = "Condition", var = "Focused" }) } end, + ["(%d+)%% chance for skills to not consume a cooldown on use"] = function(num) return { mod("CooldownNotConsumedChance", "BASE", num) } end, + ["(%d+)%% chance for skills to not consume a cooldown on use limited to 1 runegraft of time"] = function(num) return { mod("CooldownNotConsumedChance", "BASE", num) } end, ["(%d+)%% more frozen legion and general's cry cooldown recovery rate"] = function(num) return { mod("CooldownRecovery", "MORE", num, { type = "SkillName", skillNameList = { "Frozen Legion", "General's Cry" }, includeTransfigured = true }) } end, ["flamethrower, seismic and lightning spire trap have (%d+)%% increased cooldown recovery rate"] = function(num) return { mod("CooldownRecovery", "INC", num, { type = "SkillName", skillNameList = { "Flamethrower Trap", "Seismic Trap", "Lightning Spire Trap" }, includeTransfigured = true }) } end, ["flamethrower, seismic and lightning spire trap have %-(%d+) cooldown uses?"] = function(num) return { mod("AdditionalCooldownUses", "BASE", -num, { type = "SkillName", skillNameList = { "Flamethrower Trap", "Seismic Trap", "Lightning Spire Trap" }, includeTransfigured = true }) } end,