From 2b52e326e10c6a035b5164cae48ec369053d1e17 Mon Sep 17 00:00:00 2001 From: Michael Darmousseh Date: Mon, 13 Jul 2026 14:52:54 -0500 Subject: [PATCH 1/3] Add pseudo-hit support for ailment sources Some skills inflict an ailment as though dealing damage without actually hitting (e.g. Infernal Legion's burn "ignites as though dealing X"). Add a generic skillData flag, pseudoHitAilment, for such skills: - The skill inflicts only the ailment it represents: the restriction lives in canDoAilment, so on-hit ailment sources (a Poison support, etc.) can't ride along on the notional damage. This works the same for every ailment. - The hit itself deals no damage: hit outputs are zeroed after the ailment has been derived from the stored hit damage, so only the ailment/DoT counts toward DPS (and Full DPS aggregation). No skill sets the flag yet; Infernal Legion support builds on this. --- spec/System/TestAilments_spec.lua | 52 +++++++++++++++++++++++++++++++ src/Modules/CalcOffence.lua | 21 +++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/spec/System/TestAilments_spec.lua b/spec/System/TestAilments_spec.lua index 83f6ac5f2d..4b9b1f44a9 100644 --- a/spec/System/TestAilments_spec.lua +++ b/spec/System/TestAilments_spec.lua @@ -28,4 +28,56 @@ describe("TestAilments", function() assert.are.equals(1.1, build.calcsTab.mainOutput.PoisonEffMult) end) + + -- Pseudo-hits: a skill flagged with skillData.pseudoHitAilment inflicts that one + -- ailment as though dealing its hit damage, but the hit itself deals no damage + -- (e.g. Infernal Legion's burn). No in-tree skill sets the flag yet, so run the + -- calcs directly on an env with the flag injected. + describe("pseudo-hit ailment sources", function() + local function setupFireball(customMods) + build.skillsTab:PasteSocketGroup("Fireball 20/0 1\n") + runCallback("OnFrame") + build.configTab.input.customMods = customMods + build.configTab:BuildModList() + runCallback("OnFrame") + end + + local function performWith(pseudoHitAilment) + local calcs = build.calcsTab.calcs + local env = calcs.initEnv(build, "MAIN") + env.player.mainSkill.skillData.pseudoHitAilment = pseudoHitAilment + calcs.perform(env) + return env.player.output + end + + it("deals no hit damage but still applies its ailment", function() + setupFireball("100% chance to Ignite") + local real = performWith(nil) + assert.True(real.TotalDPS and real.TotalDPS > 0) + assert.True(real.IgniteDPS and real.IgniteDPS > 0) + local pseudo = performWith("Ignite") + assert.are.equals(0, pseudo.TotalDPS) + assert.are.equals(0, pseudo.AverageDamage) + -- the ailment still sees the full stored hit damage + assert.are.equals(real.IgniteDPS, pseudo.IgniteDPS) + end) + + it("does not let other on-hit ailments ride on the notional damage", function() + setupFireball("100% chance to Ignite\n100% chance to Poison on Hit\nAdds 50 to 70 Chaos Damage to Spells") + local real = performWith(nil) + assert.True(real.PoisonDPS and real.PoisonDPS > 0) + local pseudo = performWith("Ignite") + assert.True(not pseudo.PoisonDPS or pseudo.PoisonDPS == 0, "pseudo-hit must not produce poison DPS") + assert.are.equals(real.IgniteDPS, pseudo.IgniteDPS) + end) + + it("still scales with ailment magnitude", function() + setupFireball("100% chance to Ignite") + local base = performWith("Ignite").IgniteDPS + assert.True(base and base > 0) + setupFireball("100% chance to Ignite\n50% increased Magnitude of Ailments") + local scaled = performWith("Ignite").IgniteDPS + assert.True(math.abs(scaled / base - 1.5) < 1e-6, string.format("expected 1.5x, got %.6fx", scaled / base)) + end) + end) end) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index b6de2bf85d..cc742f5661 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -4836,6 +4836,12 @@ function calcs.offence(env, actor, activeSkill) if not canDeal[damageType] then return false end + -- A pseudo-hit (e.g. Infernal Legion "ignites as though dealing X") is not a + -- real hit, so it only inflicts the one ailment it represents -- on-hit ailment + -- sources (a Poison support, etc.) can't ride along on its notional damage. + if skillData.pseudoHitAilment and ailmentType ~= skillData.pseudoHitAilment then + return false + end -- check against input valid types if ((defaultDamageTypes and defaultDamageTypes[damageType]) or (ailmentData[ailmentType] and damageType == ailmentData[ailmentType].associatedType)) then @@ -5488,8 +5494,9 @@ function calcs.offence(env, actor, activeSkill) end end - -- Calculate damaging ailment values - for _, damagingAilment in ipairs({"Bleed", "Poison", "Ignite"}) do + -- Calculate damaging ailment values. A pseudo-hit only seeds the ailment it + -- represents; that restriction lives in canDoAilment, so the loop stays general. + for _, damagingAilment in ipairs({ "Bleed", "Poison", "Ignite" }) do local damageType = data.defaultAilmentDamageTypes[damagingAilment]["DamageType"] if not canDeal[damageType] then for _, type in ipairs(dmgTypeList) do @@ -6133,6 +6140,16 @@ function calcs.offence(env, actor, activeSkill) end end + -- pseudoHitAilment: the hit deals no damage of its own (e.g. Infernal Legion + -- "ignites as though dealing X"), it only seeds its ailment, which was already + -- derived from the stored hit damage above. Zero the hit outputs so only the + -- ailment/DoT counts toward DPS. + if skillData.pseudoHitAilment then + output.AverageDamage = 0 + output.AverageHit = 0 + output.TotalDPS = 0 + end + -- Calculate combined DPS estimate, including DoTs local baseDPS = output[(skillData.showAverage and "AverageDamage") or "TotalDPS"] output.CombinedDPS = baseDPS From 9ff511ecb028f5b62bbcfd7aaec976f86f83aefe Mon Sep 17 00:00:00 2001 From: Michael Darmousseh Date: Mon, 13 Jul 2026 18:41:22 -0500 Subject: [PATCH 2/3] Add support for the Infernal Legion skill Model the PoE2 Infernal Legion burn as a hidden minion skill granted by the Infernal Legion supports and Catha's Brilliance, which share the support_minions_ignite_for_%_max_life stat. PoE1's PoB models Infernal Legion as a FireDot aura (same shape as Righteous Fire) because the PoE1 support dealt fire damage per second. PoE2 changed the mechanic to an Ignite, which a plain DoT can't express (ailment magnitude, crit via Critical Weakness, ignite stacking). Keep the PoE1 hidden-skill architecture but change the payload: - The burn is a pseudo-hit Ignite: X% of the minion's maximum life is fed in as notional base fire damage, run through the normal damage pipeline, then discarded as a hit (pseudoHitAilment). - Fixed ignite cadence (~0.8/sec via timeOverride, matched to in-game timing); with the 4s ignite duration this drives the rolling-ignite stack potential. - Crit: no innate crit chance, so it crits only from enemy Critical Weakness; the minion's increased crit chance and crit damage each apply at half effectiveness; 50% base crit damage bonus instead of the minion's 100%. - Full DPS evaluates the IL extra skill even when another minion skill is selected, feeding the strongest-ignite comparison. --- spec/System/TestInfernalLegion_spec.lua | 90 +++++++++++++++++++++++++ src/Data/SkillStatMap.lua | 3 + src/Data/Skills/minion.lua | 52 ++++++++++++++ src/Data/Skills/other.lua | 9 +++ src/Data/Skills/sup_str.lua | 15 ++--- src/Export/Skills/minion.txt | 52 ++++++++++++++ src/Export/Skills/other.txt | 9 +++ src/Export/Skills/sup_str.txt | 15 ++--- src/Modules/Calcs.lua | 17 +++++ 9 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 spec/System/TestInfernalLegion_spec.lua diff --git a/spec/System/TestInfernalLegion_spec.lua b/spec/System/TestInfernalLegion_spec.lua new file mode 100644 index 0000000000..7c718f048d --- /dev/null +++ b/spec/System/TestInfernalLegion_spec.lua @@ -0,0 +1,90 @@ +describe("TestInfernalLegion", function() + before_each(function() + newBuild() + end) + + -- Helper: minion supported by Infernal Legion, with the minion pointed at the IL skill. + -- Set any config/customMods before calling this so the build picks them up. + local function setupIL() + build.configTab:BuildModList() + build.skillsTab:PasteSocketGroup("Skeletal Warrior 20/0 1\nInfernal Legion I 1/0 1\n") + runCallback("OnFrame") + build.skillsTab:SetDisplayGroup(build.skillsTab.socketGroupList[1]) + runCallback("OnFrame") + local env = build.calcsTab.mainEnv + local ilIndex + for i, s in ipairs(env.minion and env.minion.activeSkillList or {}) do + local ge = s.activeEffect and s.activeEffect.grantedEffect + if ge and ge.id == "InfernalLegion" then ilIndex = i break end + end + assert.is_not_nil(ilIndex, "Infernal Legion should be on the minion's skill list") + for _, gem in ipairs(build.skillsTab.socketGroupList[1].gemList) do + gem.skillMinionSkill = ilIndex + gem.skillMinionSkillCalcs = ilIndex + end + build.mainSocketGroup = 1 + build.modFlag = true; build.buildFlag = true + for _ = 1, 6 do runCallback("OnFrame") end + return build.calcsTab.mainEnv + end + + local function recalc() + build.configTab:BuildModList() + build.modFlag = true; build.buildFlag = true + for _ = 1, 4 do runCallback("OnFrame") end + return build.calcsTab.mainEnv + end + + -- IL's "hit" is a pseudo-hit: it seeds the ignite but deals no damage of its own. + it("IL deals no hit damage (pseudo-hit) but still ignites", function() + local o = setupIL().minion.output + assert.are.equals(0, o.AverageDamage) + assert.are.equals(0, o.TotalDPS) + assert.True(o.IgniteDPS and o.IgniteDPS > 0, "IL should produce ignite DPS") + end) + + -- IL ignite crits use a 50% base crit bonus (1.5x), not the minion's default 100% (2x). + it("IL uses a 50% base crit bonus (1.5x multiplier)", function() + assert.are.equals(1.5, setupIL().minion.output.PreEffectiveCritMultiplier) + end) + + -- The minion's *increased* crit damage applies at half effectiveness for IL. + it("the minion's increased crit damage is half-effective for IL", function() + build.configTab.input.customMods = "Minions have 100% increased Critical Damage Bonus" + -- base 0.5 bonus, +100% increased applied at half (=+50%): 0.5 * (1 + 0.5) = 0.75 -> 1.75x. + -- (Full effectiveness would give 0.5 * 2.0 = 1.0 -> 2.0x.) + assert.are.equals(1.75, setupIL().minion.output.PreEffectiveCritMultiplier) + end) + + -- "Minions have X% increased Magnitude of Ailments" scales IL's ignite end-to-end. + it("minion 'increased Magnitude of Ailments' scales IL ignite", function() + local base = setupIL().minion.output.IgniteDPS + assert.True(base and base > 0) + build.configTab.input.customMods = "Minions have 100% increased Magnitude of Ailments" + local scaled = recalc().minion.output.IgniteDPS + -- +100% magnitude roughly doubles the ignite (baseline magnitude increase is 0 here). + assert.True(math.abs(scaled - 2 * base) < 1e-6, string.format("expected %.6f, got %.6f", 2 * base, scaled)) + end) + + -- Full DPS runs offence on the IL skill an extra time. The half-effect that + -- preDamageFunc injects must not stack, or crit would over-halve. + it("IL crit/ignite stay correct under Full DPS (no double-applied halving)", function() + local base = setupIL().minion.output.IgniteDPS + build.skillsTab.socketGroupList[1].includeInFullDPS = true + local env = recalc() + assert.are.equals(1.5, env.minion.output.PreEffectiveCritMultiplier) + assert.True(math.abs(env.minion.output.IgniteDPS - base) < 1e-6, + string.format("ignite changed under Full DPS: %.6f vs %.6f", env.minion.output.IgniteDPS, base)) + end) + + -- IL's hit is a pseudo-hit (no real damage), so a poison source must not seed a + -- poison off its big notional damage -- that was inflating Poison's evaluated value. + it("IL pseudo-hit does not seed poison from a poison source", function() + local igniteBefore = setupIL().minion.output.IgniteDPS + build.configTab.input.customMods = "Minions have 100% chance to Poison on Hit" + local env = recalc() + local poison = env.minion.output.PoisonDPS + assert.True(not poison or poison == 0, "IL pseudo-hit must not produce poison DPS") + assert.True(math.abs(env.minion.output.IgniteDPS - igniteBefore) < 1e-6, "ignite must be unchanged") + end) +end) diff --git a/src/Data/SkillStatMap.lua b/src/Data/SkillStatMap.lua index b48427f740..fda220e422 100644 --- a/src/Data/SkillStatMap.lua +++ b/src/Data/SkillStatMap.lua @@ -642,6 +642,9 @@ return { ["active_skill_base_radius_+"] = { skill("radiusExtra", nil), }, +["infernal_legion_minion_burning_effect_radius"] = { + skill("radius", nil), +}, ["base_skill_area_of_effect_+%"] = { mod("AreaOfEffect", "INC", nil), }, diff --git a/src/Data/Skills/minion.lua b/src/Data/Skills/minion.lua index 44310100db..d1642a8401 100644 --- a/src/Data/Skills/minion.lua +++ b/src/Data/Skills/minion.lua @@ -1132,6 +1132,58 @@ skills["MPSAncestralTotemSpiritSoulCasterProjectile"] = { } } + skills["InfernalLegion"] = { + name = "Infernal Legion", + hidden = true, + skillTypes = { + [SkillType.Damage] = true, + [SkillType.Area] = true, + [SkillType.Fire] = true, + [SkillType.CausesBurning] = true, + }, + qualityStats = { + }, + levels = { + [1] = { critChance = 0, levelRequirement = 0, }, -- only crits from enemy Critical Weakness + }, + preDamageFunc = function(activeSkill, output) + local skillData = activeSkill.skillData + local sml = activeSkill.skillModList + local cfg = activeSkill.skillCfg + local base = output.Life * skillData.selfFireExplosionLifeMultiplier + skillData.FireBonusMin = base + skillData.FireBonusMax = base + -- minion's increased crit chance/damage are half-effective for IL + sml:NewMod("CritChance", "INC", -0.5 * sml:Sum("INC", cfg, "CritChance"), "Infernal Legion") + sml:NewMod("CritMultiplier", "INC", -0.5 * sml:Sum("INC", cfg, "CritMultiplier"), "Infernal Legion") + end, + statSets = { + [1] = { + label = "Infernal Legion", + incrementalEffectiveness = 0, + statDescriptionScope = "skill_stat_descriptions", + baseFlags = { + area = true, + fire = true, + }, + baseMods = { + skill("selfFireExplosionLifeMultiplier", 0.01, { type = "Multiplier", var = "InfernalLegionBaseDamage" }), + skill("pseudoHitAilment", "Ignite"), -- fake hit: only seeds Ignite, no hit damage + skill("timeOverride", 1.25), -- fixed ~0.8/sec ignite cadence, not attack speed + mod("EnemyIgniteChance", "BASE", 100), + mod("CritMultiplier", "BASE", -50), -- 50% base crit bonus, not the minion's 100% + }, + constantStats = { + }, + stats = { + }, + levels = { + [1] = { }, + }, + }, + } + } + skills["GAAnimateWeaponMaceSlam"] = { name = "Mace Slam", hidden = true, diff --git a/src/Data/Skills/other.lua b/src/Data/Skills/other.lua index 1d7011b75a..ce5069fb6e 100644 --- a/src/Data/Skills/other.lua +++ b/src/Data/Skills/other.lua @@ -3556,6 +3556,15 @@ skills["SupportCathasBrilliance"] = { label = "Catha's Brilliance", incrementalEffectiveness = 0.054999999701977, statDescriptionScope = "gem_stat_descriptions", + statMap = { + -- Catha's Brilliance ignites enemies for a % of the minion's max life, the + -- same mechanic as Infernal Legion: wire the stat to the IL base-damage + -- multiplier and grant the companion the IL skill so the ignite is modelled. + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), + mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), + }, + }, baseFlags = { }, constantStats = { diff --git a/src/Data/Skills/sup_str.lua b/src/Data/Skills/sup_str.lua index 07a6f23b69..bb5ad5d054 100644 --- a/src/Data/Skills/sup_str.lua +++ b/src/Data/Skills/sup_str.lua @@ -4634,9 +4634,8 @@ skills["SupportInfernalLegionPlayer"] = { mod("MinionModifier", "LIST", { mod = mod("FireDegen", "BASE", nil, 0, 0, { type = "PerStat", stat = "Life" }, { type = "GlobalEffect", effectType = "Buff" }) }), div = 6000, }, - ["support_minion_instability_minion_base_fire_area_damage_per_minute"] = { - mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil, 0, 0, { type = "PercentStat", stat = "Life", percent = 1 }) }), - div = 60, + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), }, }, @@ -4677,9 +4676,8 @@ skills["SupportInfernalLegionPlayerTwo"] = { mod("MinionModifier", "LIST", { mod = mod("FireDegen", "BASE", nil, 0, 0, { type = "PerStat", stat = "Life" }, { type = "GlobalEffect", effectType = "Buff" }) }), div = 6000, }, - ["support_minion_instability_minion_base_fire_area_damage_per_minute"] = { - mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil, 0, 0, { type = "PercentStat", stat = "Life", percent = 1 }) }), - div = 60, + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), }, }, @@ -4722,9 +4720,8 @@ skills["SupportInfernalLegionPlayerThree"] = { mod("MinionModifier", "LIST", { mod = mod("FireDegen", "BASE", nil, 0, 0, { type = "PerStat", stat = "Life" }, { type = "GlobalEffect", effectType = "Buff" }) }), div = 6000, }, - ["support_minion_instability_minion_base_fire_area_damage_per_minute"] = { - mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil, 0, 0, { type = "PercentStat", stat = "Life", percent = 1 }) }), - div = 60, + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), }, }, diff --git a/src/Export/Skills/minion.txt b/src/Export/Skills/minion.txt index 19988ad829..5bd04c1451 100644 --- a/src/Export/Skills/minion.txt +++ b/src/Export/Skills/minion.txt @@ -164,6 +164,58 @@ skills["MinionInstability"] = { } } +skills["InfernalLegion"] = { + name = "Infernal Legion", + hidden = true, + skillTypes = { + [SkillType.Damage] = true, + [SkillType.Area] = true, + [SkillType.Fire] = true, + [SkillType.CausesBurning] = true, + }, + qualityStats = { + }, + levels = { + [1] = { critChance = 0, levelRequirement = 0, }, -- only crits from enemy Critical Weakness + }, + preDamageFunc = function(activeSkill, output) + local skillData = activeSkill.skillData + local sml = activeSkill.skillModList + local cfg = activeSkill.skillCfg + local base = output.Life * skillData.selfFireExplosionLifeMultiplier + skillData.FireBonusMin = base + skillData.FireBonusMax = base + -- minion's increased crit chance/damage are half-effective for IL + sml:NewMod("CritChance", "INC", -0.5 * sml:Sum("INC", cfg, "CritChance"), "Infernal Legion") + sml:NewMod("CritMultiplier", "INC", -0.5 * sml:Sum("INC", cfg, "CritMultiplier"), "Infernal Legion") + end, + statSets = { + [1] = { + label = "Infernal Legion", + incrementalEffectiveness = 0, + statDescriptionScope = "skill_stat_descriptions", + baseFlags = { + area = true, + fire = true, + }, + baseMods = { + skill("selfFireExplosionLifeMultiplier", 0.01, { type = "Multiplier", var = "InfernalLegionBaseDamage" }), + skill("pseudoHitAilment", "Ignite"), -- fake hit: only seeds Ignite, no hit damage + skill("timeOverride", 1.25), -- fixed ~0.8/sec ignite cadence, not attack speed + mod("EnemyIgniteChance", "BASE", 100), + mod("CritMultiplier", "BASE", -50), -- 50% base crit bonus, not the minion's 100% + }, + constantStats = { + }, + stats = { + }, + levels = { + [1] = { }, + }, + }, + } +} + #addSkillTypes Melee MeleeSingleTarget Area #skill GAAnimateWeaponMaceSlam Mace Slam #set GAAnimateWeaponMaceSlam diff --git a/src/Export/Skills/other.txt b/src/Export/Skills/other.txt index a28299987a..835ea49905 100644 --- a/src/Export/Skills/other.txt +++ b/src/Export/Skills/other.txt @@ -241,6 +241,15 @@ statMap = { #skill SupportCathasBrilliance #set SupportCathasBrilliance +statMap = { + -- Catha's Brilliance ignites enemies for a % of the minion's max life, the + -- same mechanic as Infernal Legion: wire the stat to the IL base-damage + -- multiplier and grant the companion the IL skill so the ignite is modelled. + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), + mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), + }, +}, #mods #skillEnd diff --git a/src/Export/Skills/sup_str.txt b/src/Export/Skills/sup_str.txt index aed131df6a..9a12ac0773 100644 --- a/src/Export/Skills/sup_str.txt +++ b/src/Export/Skills/sup_str.txt @@ -1046,9 +1046,8 @@ statMap = { mod("MinionModifier", "LIST", { mod = mod("FireDegen", "BASE", nil, 0, 0, { type = "PerStat", stat = "Life" }, { type = "GlobalEffect", effectType = "Buff" }) }), div = 6000, }, - ["support_minion_instability_minion_base_fire_area_damage_per_minute"] = { - mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil, 0, 0, { type = "PercentStat", stat = "Life", percent = 1 }) }), - div = 60, + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), }, }, @@ -1062,9 +1061,8 @@ statMap = { mod("MinionModifier", "LIST", { mod = mod("FireDegen", "BASE", nil, 0, 0, { type = "PerStat", stat = "Life" }, { type = "GlobalEffect", effectType = "Buff" }) }), div = 6000, }, - ["support_minion_instability_minion_base_fire_area_damage_per_minute"] = { - mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil, 0, 0, { type = "PercentStat", stat = "Life", percent = 1 }) }), - div = 60, + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), }, }, @@ -1079,9 +1077,8 @@ statMap = { mod("MinionModifier", "LIST", { mod = mod("FireDegen", "BASE", nil, 0, 0, { type = "PerStat", stat = "Life" }, { type = "GlobalEffect", effectType = "Buff" }) }), div = 6000, }, - ["support_minion_instability_minion_base_fire_area_damage_per_minute"] = { - mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil, 0, 0, { type = "PercentStat", stat = "Life", percent = 1 }) }), - div = 60, + ["support_minions_ignite_for_%_max_life"] = { + mod("MinionModifier", "LIST", { mod = mod("Multiplier:InfernalLegionBaseDamage", "BASE", nil) }), mod("ExtraMinionSkill", "LIST", { skillId = "InfernalLegion" }), }, }, diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index f8cf2e8278..31598c4f54 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -384,6 +384,23 @@ function calcs.calcFullDPS(build, mode, override, specEnv) activeSkillCount = 1 activeSkill.infoMessage2 = "Skill Damage" end + -- Infernal Legion is an always-active burning aura the minion emits. + -- When the minion is in Full DPS, evaluate its IL extra-skill ignite + -- (even when a different skill is selected) and feed it into the best + -- ignite, since only the strongest ignite can be on a target at once. + local ilSkill + for _, s in ipairs(usedEnv.minion.activeSkillList or {}) do + local ge = s.activeEffect and s.activeEffect.grantedEffect + if ge and ge.id == "InfernalLegion" and s ~= usedEnv.minion.mainSkill then ilSkill = s break end + end + if ilSkill then + usedEnv.minion.mainSkill = ilSkill + calcs.offence(usedEnv, usedEnv.minion, ilSkill) + if usedEnv.minion.output.IgniteDPS and usedEnv.minion.output.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = usedEnv.minion.output.IgniteDPS + igniteSource = skillName .. " (Infernal Legion)" + end + end end local playerOut = captureFields(usedEnv.player.output) From 1cfb472bd15322cacbdc0e67df36f0c69913e4f8 Mon Sep 17 00:00:00 2001 From: Michael Darmousseh Date: Mon, 13 Jul 2026 19:04:32 -0500 Subject: [PATCH 3/3] Keep the Infernal Legion ignite in cached Full DPS passes The IL extra-skill ignite was written straight into the Full DPS roll-up, so it was not part of the captured pass snapshot: a cached replay (item/tree comparison with unchanged inputs) silently dropped the ignite and understated Full DPS. Record the IL result as its own actor in the pass instead - mergeStats feeds it into the best-ignite comparison on live and cached merges alike, and the source column now names the skill (the old path set an unused global, leaving the Best Ignite DPS source blank). --- spec/System/TestInfernalLegion_spec.lua | 32 +++++++++++++++++++++++++ src/Modules/Calcs.lua | 17 ++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/spec/System/TestInfernalLegion_spec.lua b/spec/System/TestInfernalLegion_spec.lua index 7c718f048d..6c6fcace41 100644 --- a/spec/System/TestInfernalLegion_spec.lua +++ b/spec/System/TestInfernalLegion_spec.lua @@ -77,6 +77,38 @@ describe("TestInfernalLegion", function() string.format("ignite changed under Full DPS: %.6f vs %.6f", env.minion.output.IgniteDPS, base)) end) + -- The IL extra-skill result must live in the pass snapshot: a cached Full DPS + -- replay (item/tree comparison) has to keep the IL ignite, not silently drop it. + it("IL ignite survives a cached Full DPS replay", function() + local env = setupIL() + -- select a non-IL skill as the minion's main skill; IL stays granted as the + -- extra skill, so its ignite only enters Full DPS via the extra-skill pass + local otherIndex + for i, s in ipairs(env.minion.activeSkillList) do + local ge = s.activeEffect and s.activeEffect.grantedEffect + if not (ge and ge.id == "InfernalLegion") then otherIndex = i break end + end + assert.is_not_nil(otherIndex, "no non-IL minion skill found") + for _, gem in ipairs(build.skillsTab.socketGroupList[1].gemList) do + gem.skillMinionSkill = otherIndex + gem.skillMinionSkillCalcs = otherIndex + end + build.skillsTab.socketGroupList[1].includeInFullDPS = true + recalc() + local calcsModule = LoadModule("Modules/Calcs") + local store = { } + local fresh = calcsModule.calcFullDPS(build, "CALCULATOR", {}, { fullDPSCache = { store = store, capture = true } }) + local performs = 0 + local realPerform = calcsModule.perform + calcsModule.perform = function(...) performs = performs + 1 return realPerform(...) end + local cached = calcsModule.calcFullDPS(build, "CALCULATOR", {}, { fullDPSCache = { store = store } }) + calcsModule.perform = realPerform + assert.are.equals(0, performs, "second call should replay from the cache") + assert.True(fresh.igniteDPS and fresh.igniteDPS > 0, "fresh pass should have the IL ignite") + assert.are.equals(fresh.igniteDPS, cached.igniteDPS) + assert.are.equals(fresh.TotalDotDPS, cached.TotalDotDPS) + end) + -- IL's hit is a pseudo-hit (no real damage), so a poison source must not seed a -- poison off its big notional damage -- that was inflating Poison's evaluated value. it("IL pseudo-hit does not seed poison from a poison source", function() diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 31598c4f54..c711d088cc 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -386,8 +386,11 @@ function calcs.calcFullDPS(build, mode, override, specEnv) end -- Infernal Legion is an always-active burning aura the minion emits. -- When the minion is in Full DPS, evaluate its IL extra-skill ignite - -- (even when a different skill is selected) and feed it into the best - -- ignite, since only the strongest ignite can be on a target at once. + -- (even when a different skill is selected) and add it to the pass as + -- its own actor: mergeStats feeds it into the best ignite (only the + -- strongest ignite can be on a target at once), and being part of the + -- pass snapshot it survives cached replays. Its hit DPS is zero + -- (pseudo-hit), so it contributes nothing else to the totals. local ilSkill for _, s in ipairs(usedEnv.minion.activeSkillList or {}) do local ge = s.activeEffect and s.activeEffect.grantedEffect @@ -396,10 +399,12 @@ function calcs.calcFullDPS(build, mode, override, specEnv) if ilSkill then usedEnv.minion.mainSkill = ilSkill calcs.offence(usedEnv, usedEnv.minion, ilSkill) - if usedEnv.minion.output.IgniteDPS and usedEnv.minion.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = usedEnv.minion.output.IgniteDPS - igniteSource = skillName .. " (Infernal Legion)" - end + t_insert(pass.actors, { + out = captureFields(usedEnv.minion.output), + name = skillName .. " (Infernal Legion)", + count = 1, + sourceName = skillName .. " (Infernal Legion)", + }) end end