diff --git a/spec/System/TestCompanionFullDPS_spec.lua b/spec/System/TestCompanionFullDPS_spec.lua new file mode 100644 index 000000000..244c5829c --- /dev/null +++ b/spec/System/TestCompanionFullDPS_spec.lua @@ -0,0 +1,324 @@ +describe("TestCompanionFullDPS", function() + before_each(function() + newBuild() + end) + + -- Mighty Silverfist has exactly two attacks with skill data: + -- [1] MeleeAtAnimationSpeedUnique (Basic Attack), [2] GAQuadrillaBossRectSlam (Pillar Slam) + local beastId = "Metadata/Monsters/Quadrilla/QuadrillaBossMinion1" + local meleeId = "MeleeAtAnimationSpeedUnique" + local slamId = "GAQuadrillaBossRectSlam" + + local function buildCompanionGroup(fullDPSMinionSkills) + table.insert(build.beastList, beastId) + local gemInstance = { + nameSpec = "Companion: Mighty Silverfist", + gemId = "Metadata/Items/Gems/SkillGemSummonBeast", + level = 20, quality = 0, enabled = true, enableGlobal1 = true, enableGlobal2 = true, + count = 1, corrupted = false, corruptLevel = 0, + skillMinion = beastId, + skillMinionCalcs = beastId, + fullDPSMinionSkills = fullDPSMinionSkills, + } + local group = { label = "", enabled = true, includeInFullDPS = true, gemList = { gemInstance } } + table.insert(build.skillsTab.socketGroupList, group) + build.skillsTab:ProcessSocketGroup(group) + build.mainSocketGroup = #build.skillsTab.socketGroupList + build.buildFlag = true + runCallback("OnFrame") + return gemInstance, group + end + + local function companionEntries() + local entries = { } + for _, entry in ipairs(build.calcsTab.mainOutput.SkillDPS or { }) do + if entry.source and entry.source:match("^Companion") then + table.insert(entries, entry) + end + end + return entries + end + + it("counts only the active attack when no selection is made", function() + local gemInstance = buildCompanionGroup(nil) + + local entries = companionEntries() + assert.are.equals(1, #entries) + -- the attack leads the entry; the companion is the source line + assert.are.equals("Basic Attack", entries[1].name) + assert.are.equals("Companion: Mighty Silverfist", entries[1].source) + assert.is_nil(entries[1].skillPart) + assert.True(entries[1].dps > 0) + assert.is_nil(gemInstance.fullDPSMinionSkills) + end) + + it("creates one Full DPS entry per selected attack and sums them", function() + local gemInstance = buildCompanionGroup({ [meleeId] = true, [slamId] = true }) + + local entries = companionEntries() + assert.are.equals(2, #entries) + assert.are_not.equals(entries[1].name, entries[2].name) + local sum = 0 + for _, entry in ipairs(entries) do + assert.are.equals("Companion: Mighty Silverfist", entry.source) + assert.True(entry.dps > 0) + sum = sum + entry.dps * entry.count + end + assert.True(math.abs(build.calcsTab.mainOutput.FullDPS - sum) < 0.01) + -- the calc must not leak its per-pass override or touch persisted fields + assert.are.equals(beastId, gemInstance.skillMinion) + assert.True(gemInstance.fullDPSMinionSkills[meleeId]) + assert.True(gemInstance.fullDPSMinionSkills[slamId]) + for _, activeSkill in ipairs(build.calcsTab.mainEnv.player.activeSkillList) do + assert.is_nil(activeSkill.minionSkillIndexOverride) + end + end) + + it("excludes the active attack when only another attack is selected", function() + buildCompanionGroup({ [slamId] = true }) + + local entries = companionEntries() + assert.are.equals(1, #entries) + assert.are.equals("Pillar Slam", entries[1].name) + end) + + it("falls back to the active attack when no selected id matches the beast", function() + buildCompanionGroup({ NotARealMinionSkillId = true }) + + local entries = companionEntries() + assert.are.equals(1, #entries) + assert.are.equals("Basic Attack", entries[1].name) + end) + + it("lists other minions' entries in the same attack-plus-source format", function() + build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") + local group = build.skillsTab.socketGroupList[#build.skillsTab.socketGroupList] + group.includeInFullDPS = true + build.buildFlag = true + runCallback("OnFrame") + + local entry + for _, skillEntry in ipairs(build.calcsTab.mainOutput.SkillDPS or { }) do + if skillEntry.source == "Skeletal Sniper Minion" then + entry = skillEntry + end + end + assert.is_not_nil(entry) + assert.is_nil(entry.skillPart) + assert.True(entry.dps > 0) + end) + + it("a single selected attack matches the unselected baseline DPS", function() + buildCompanionGroup(nil) + local baseline = companionEntries()[1] + + newBuild() + buildCompanionGroup({ [meleeId] = true }) + local selected = companionEntries()[1] + + assert.are.equals(baseline.dps, selected.dps) + assert.are.equals(baseline.name, selected.name) + end) + + describe("persistence", function() + it("saves selected attacks as sorted Gem child elements", function() + build.skillsTab.skillSets[1].socketGroupList = { { + enabled = true, + gemList = { { + nameSpec = "Companion: Mighty Silverfist", + level = 20, quality = 0, enabled = true, enableGlobal1 = true, enableGlobal2 = true, + count = 1, corrupted = false, corruptLevel = 0, + fullDPSMinionSkills = { [slamId] = true, [meleeId] = true }, + } }, + } } + + local xml = { } + build.skillsTab:Save(xml) + + local gemNode + for _, skillSetNode in ipairs(xml) do + if skillSetNode.elem == "SkillSet" then + for _, skillNode in ipairs(skillSetNode) do + if skillNode.elem == "Skill" then + gemNode = skillNode[1] + end + end + end + end + assert.is_not_nil(gemNode) + + local skillIds = { } + for _, child in ipairs(gemNode) do + if child.elem == "FullDPSMinionSkill" then + table.insert(skillIds, child.attrib.skillId) + end + end + assert.are.equals(2, #skillIds) + -- sorted for deterministic build XML + assert.are.equals(slamId, skillIds[1]) + assert.are.equals(meleeId, skillIds[2]) + end) + + it("loads FullDPSMinionSkill elements back onto the gem instance", function() + local node = { elem = "Skill", attrib = { enabled = "true" }, + { elem = "Gem", attrib = { nameSpec = "Companion: Mighty Silverfist", level = "20", quality = "0", enabled = "true" }, + { elem = "FullDPSMinionSkill", attrib = { skillId = meleeId } }, + { elem = "FullDPSMinionSkill", attrib = { skillId = slamId } }, + }, + } + + build.skillsTab:LoadSkill(node, 1) + + local socketGroupList = build.skillsTab.skillSets[1].socketGroupList + local gemInstance = socketGroupList[#socketGroupList].gemList[1] + assert.is_not_nil(gemInstance.fullDPSMinionSkills) + assert.True(gemInstance.fullDPSMinionSkills[meleeId]) + assert.True(gemInstance.fullDPSMinionSkills[slamId]) + end) + + it("loads gems without a selection as nil", function() + local node = { elem = "Skill", attrib = { enabled = "true" }, + { elem = "Gem", attrib = { nameSpec = "Fireball", level = "20", quality = "0", enabled = "true" } }, + } + + build.skillsTab:LoadSkill(node, 1) + + local socketGroupList = build.skillsTab.skillSets[1].socketGroupList + assert.is_nil(socketGroupList[#socketGroupList].gemList[1].fullDPSMinionSkills) + end) + end) + + describe("SkillsTab UI", function() + local function displayLastGroup() + local skillsTab = build.skillsTab + skillsTab:SetDisplayGroup(skillsTab.socketGroupList[#skillsTab.socketGroupList]) + skillsTab:UpdateBeastAttackSlots() + return skillsTab + end + + it("creates attack rows with default check state on the active attack", function() + buildCompanionGroup(nil) + local skillsTab = displayLastGroup() + + local attacks = skillsTab:GetDisplayedBeastAttacks() + assert.is_not_nil(attacks) + assert.are.equals(2, #attacks) + assert.is_not_nil(skillsTab.beastAttackSlots[2]) + assert.True(skillsTab.beastAttackSlots[1].enabled.state) + assert.False(skillsTab.beastAttackSlots[2].enabled.state) + end) + + it("shows all attacks unchecked while the group is out of Full DPS", function() + local gemInstance, group = buildCompanionGroup({ [slamId] = true }) + group.includeInFullDPS = false + local skillsTab = displayLastGroup() + + assert.False(skillsTab.beastAttackSlots[1].enabled.state) + assert.False(skillsTab.beastAttackSlots[2].enabled.state) + end) + + it("reflects an explicit selection and replaces the table on toggle", function() + local gemInstance, group = buildCompanionGroup({ [slamId] = true }) + local skillsTab = displayLastGroup() + + assert.False(skillsTab.beastAttackSlots[1].enabled.state) + assert.True(skillsTab.beastAttackSlots[2].enabled.state) + + -- toggling builds a new table (undo snapshots share nested tables) + local before = gemInstance.fullDPSMinionSkills + skillsTab.beastAttackSlots[1].enabled.changeFunc(true) + assert.are_not.equals(before, gemInstance.fullDPSMinionSkills) + assert.True(gemInstance.fullDPSMinionSkills[meleeId]) + assert.True(gemInstance.fullDPSMinionSkills[slamId]) + assert.True(group.includeInFullDPS) + + -- unchecking everything clears the selection and leaves Full DPS, + -- updating the group's checkbox state as well + skillsTab.beastAttackSlots[1].enabled.changeFunc(false) + skillsTab.beastAttackSlots[2].enabled.changeFunc(false) + assert.is_nil(gemInstance.fullDPSMinionSkills) + assert.False(group.includeInFullDPS) + assert.False(skillsTab.controls.includeInFullDPS.state) + end) + + it("checking an attack pulls the group into Full DPS", function() + local gemInstance, group = buildCompanionGroup(nil) + group.includeInFullDPS = false + local skillsTab = displayLastGroup() + + skillsTab.beastAttackSlots[2].enabled.changeFunc(true) + assert.True(group.includeInFullDPS) + assert.True(skillsTab.controls.includeInFullDPS.state) + assert.True(gemInstance.fullDPSMinionSkills[slamId]) + assert.is_nil(gemInstance.fullDPSMinionSkills[meleeId]) + end) + + it("toggling Include in Full DPS syncs the attack selection", function() + local gemInstance, group = buildCompanionGroup({ [slamId] = true }) + local skillsTab = displayLastGroup() + + skillsTab.controls.includeInFullDPS.changeFunc(false) + assert.False(group.includeInFullDPS) + assert.is_nil(gemInstance.fullDPSMinionSkills) + + -- re-enabling selects the first attack + skillsTab.controls.includeInFullDPS.changeFunc(true) + assert.True(group.includeInFullDPS) + assert.True(gemInstance.fullDPSMinionSkills[meleeId]) + assert.is_nil(gemInstance.fullDPSMinionSkills[slamId]) + end) + + it("shows the skill data on hover, gem tooltip style", function() + buildCompanionGroup(nil) + local skillsTab = displayLastGroup() + + local slot = skillsTab.beastAttackSlots[2] + -- plain attack name, no (active) marker, no info button + assert.are.equals("^7Pillar Slam", slot.label.label()) + assert.is_nil(slot.info) + + local tooltip = new("Tooltip") + slot.label.tooltipFunc(tooltip) + local sawTitle, sawDamage, sawTags = false, false, false + for _, line in ipairs(tooltip.lines) do + if line.text and line.text:match("Pillar Slam") then + sawTitle = true + end + -- Pillar Slam's baseMultiplier of 3 renders as "Attack Damage: 300% of base" + if line.text and line.text:match("Attack Damage") and line.text:match("300") then + sawDamage = true + end + -- gem-style tag line from the base flags: attack, melee, area + if line.text and line.text:match("AoE, Attack, Melee") then + sawTags = true + end + end + assert.True(sawTitle) + assert.True(sawDamage) + assert.True(sawTags) + end) + + it("populates the beast dropdown from the build's beast library", function() + buildCompanionGroup(nil) + local skillsTab = displayLastGroup() + + local list = skillsTab.controls.companionBeastSelect.list + assert.are.equals(1, #list) + assert.are.equals(beastId, list[1].minionId) + end) + + it("renames the gem and refreshes the gem slot when the beast changes", function() + local crowbellId = "Metadata/Monsters/CrowBell/CrowBellBossMinion1" + local gemInstance = buildCompanionGroup(nil) + table.insert(build.beastList, crowbellId) + local skillsTab = displayLastGroup() + + skillsTab.controls.companionBeastSelect.selFunc(2, { minionId = crowbellId, label = "The Crowbell" }) + + assert.are.equals(crowbellId, gemInstance.skillMinion) + assert.are.equals("Companion: The Crowbell", gemInstance.nameSpec) + -- the visible gem name box must follow the rename + assert.are.equals("Companion: The Crowbell", skillsTab.gemSlots[1].nameSpec.buf) + end) + end) +end) diff --git a/spec/System/TestTamedBeastMods_spec.lua b/spec/System/TestTamedBeastMods_spec.lua new file mode 100644 index 000000000..5fe1888ea --- /dev/null +++ b/spec/System/TestTamedBeastMods_spec.lua @@ -0,0 +1,325 @@ +describe("TestTamedBeastMods", function() + before_each(function() + newBuild() + end) + + local sampleProperties = { + { + name = "Monster Modifiers:\n{0}", + values = { + { "[PlayerMonsterFlaskRemovalAura1|Siphons Flask Charges]\n[PlayerMonsterLifeRegenerationRatePercentage1|Regenerates Life]\nPeriodically unleashes [Cold|Ice]\n[PlayerMonsterAdditionalProjectiles1|Additional Projectiles]", 0 }, + }, + displayMode = 3, + }, + } + + describe("ImportTab.ParseTamedBeastProperties", function() + it("parses mod-id tokens and resolves display-only lines", function() + local list = build.importTab:ParseTamedBeastProperties(sampleProperties) + + assert.are.equals(4, #list) + assert.are.equals("PlayerMonsterFlaskRemovalAura1", list[1].modId) + assert.are.equals("Siphons Flask Charges", list[1].display) + assert.are.equals("PlayerMonsterLifeRegenerationRatePercentage1", list[2].modId) + -- No leading [ModId|...] token; resolved by display line lookup + assert.are.equals("Periodically unleashes Ice", list[3].display) + assert.are.equals("PlayerMonsterChilledGroundOnDeath1", list[3].modId) + assert.are.equals("PlayerMonsterAdditionalProjectiles1", list[4].modId) + for _, entry in ipairs(list) do + assert.True(entry.enabled) + end + end) + + it("keeps unresolvable lines with display text only", function() + local list = build.importTab:ParseTamedBeastProperties({ + { values = { { "[MonsterMadeUpModDoesNotExist1|Made Up Mod]", 0 } } }, + }) + + assert.are.equals(1, #list) + assert.is_nil(list[1].modId) + assert.are.equals("Made Up Mod", list[1].display) + end) + + it("returns nil for absent or empty input", function() + assert.is_nil(build.importTab:ParseTamedBeastProperties(nil)) + assert.is_nil(build.importTab:ParseTamedBeastProperties({ })) + end) + end) + + describe("Exported data shape", function() + it("contains only Player-prefixed companion mods with spawn weights", function() + local count = 0 + for modId, beastMod in pairs(build.data.tamedBeastMods) do + count = count + 1 + assert.is_not_nil(modId:match("^PlayerMonster"), modId.." is not Player-prefixed") + assert.is_nil(beastMod.rollable, modId.." still carries the removed rollable flag") + assert.are.equals("string", type(beastMod.name)) + assert.True(beastMod.type == "Prefix" or beastMod.type == "Suffix") + assert.are.equals("table", type(beastMod.statDescriptions)) + assert.are.equals("table", type(beastMod.modList)) + assert.are.equals("table", type(beastMod.spawnWeights)) + assert.True(#beastMod.spawnWeights > 0, modId.." has no spawn weights") + for _, entry in ipairs(beastMod.spawnWeights) do + assert.are.equals("string", type(entry.tag)) + assert.are.equals("number", type(entry.weight)) + end + end + -- ~68 as of 0.5; loose band so balance patches don't break the suite + assert.True(count >= 50 and count <= 100, "unexpected mod count: "..count) + -- Script-applied twin (generation type 3) must not pass the prefix/suffix filter + assert.is_nil(build.data.tamedBeastMods["PlayerMonsterGlacialPrison1"]) + end) + + it("marks boss-only beasts via the object template is_boss flag", function() + local silverfist = build.data.minions["Metadata/Monsters/Quadrilla/QuadrillaBossMinion1"] + assert.is_not_nil(silverfist) + assert.True(isValueInArray(silverfist.monsterTags, "boss") ~= nil) + local wolf = build.data.minions["WolfMinion"] + assert.is_not_nil(wolf) + assert.is_nil(isValueInArray(wolf.monsterTags, "boss")) + end) + end) + + describe("data.beastModCanSpawn", function() + it("blocks zero-weight tag matches ahead of the default entry", function() + local hasteAura = build.data.tamedBeastMods["PlayerMonsterIncreasedSpeedAura1"] + assert.is_not_nil(hasteAura) + assert.False(build.data.beastModCanSpawn(hasteAura, { "fast_movement", "beast" })) + assert.True(build.data.beastModCanSpawn(hasteAura, { "beast" })) + end) + + it("requires positive-gate tags when the default weight is zero", function() + local alwaysBleeds = build.data.tamedBeastMods["PlayerMonsterAlwaysBleed1"] + assert.is_not_nil(alwaysBleeds) + assert.True(build.data.beastModCanSpawn(alwaysBleeds, { "physical_affinity" })) + assert.False(build.data.beastModCanSpawn(alwaysBleeds, { "beast" })) + assert.False(build.data.beastModCanSpawn(alwaysBleeds, nil)) + end) + + it("falls through to false when no entry matches and there is no default", function() + local beastMod = { spawnWeights = { { tag = "caster", weight = 1 } } } + assert.False(build.data.beastModCanSpawn(beastMod, { "beast" })) + assert.True(build.data.beastModCanSpawn(beastMod, { "caster" })) + end) + end) + + describe("SkillsTab persistence", function() + it("saves tamed beast mods as Gem child elements", function() + build.skillsTab.skillSets[1].socketGroupList = { { + enabled = true, + gemList = { { + nameSpec = "Companion: Mighty Silverfist", + level = 20, quality = 0, enabled = true, enableGlobal1 = true, enableGlobal2 = true, + count = 1, corrupted = false, corruptLevel = 0, + tamedBeastModList = { + { modId = "PlayerMonsterDamageGainedAsCold1", enabled = true }, + { display = "Periodically unleashes Ice", enabled = false }, + }, + } }, + } } + + local xml = { } + build.skillsTab:Save(xml) + + local gemNode + for _, skillSetNode in ipairs(xml) do + if skillSetNode.elem == "SkillSet" then + for _, skillNode in ipairs(skillSetNode) do + if skillNode.elem == "Skill" then + gemNode = skillNode[1] + end + end + end + end + assert.is_not_nil(gemNode) + assert.are.equals("Gem", gemNode.elem) + + local beastModNodes = { } + for _, child in ipairs(gemNode) do + if child.elem == "TamedBeastMod" then + table.insert(beastModNodes, child) + end + end + assert.are.equals(2, #beastModNodes) + assert.are.equals("PlayerMonsterDamageGainedAsCold1", beastModNodes[1].attrib.modId) + assert.are.equals("true", beastModNodes[1].attrib.enabled) + assert.is_nil(beastModNodes[2].attrib.modId) + assert.are.equals("Periodically unleashes Ice", beastModNodes[2].attrib.display) + assert.are.equals("false", beastModNodes[2].attrib.enabled) + end) + + it("loads TamedBeastMod elements back onto the gem instance", function() + local node = { elem = "Skill", attrib = { enabled = "true" }, + { elem = "Gem", attrib = { nameSpec = "Companion: Mighty Silverfist", level = "20", quality = "0", enabled = "true" }, + { elem = "TamedBeastMod", attrib = { modId = "PlayerMonsterDamageGainedAsCold1", enabled = "true" } }, + { elem = "TamedBeastMod", attrib = { display = "Periodically unleashes Ice", enabled = "false" } }, + }, + } + + build.skillsTab:LoadSkill(node, 1) + + local socketGroupList = build.skillsTab.skillSets[1].socketGroupList + local gemInstance = socketGroupList[#socketGroupList].gemList[1] + assert.is_not_nil(gemInstance.tamedBeastModList) + assert.are.equals(2, #gemInstance.tamedBeastModList) + assert.are.equals("PlayerMonsterDamageGainedAsCold1", gemInstance.tamedBeastModList[1].modId) + assert.True(gemInstance.tamedBeastModList[1].enabled) + assert.is_nil(gemInstance.tamedBeastModList[2].modId) + assert.are.equals("Periodically unleashes Ice", gemInstance.tamedBeastModList[2].display) + assert.False(gemInstance.tamedBeastModList[2].enabled) + end) + + it("loads legacy gems without beast mods as nil", function() + local node = { elem = "Skill", attrib = { enabled = "true" }, + { elem = "Gem", attrib = { nameSpec = "Fireball", level = "20", quality = "0", enabled = "true" } }, + } + + build.skillsTab:LoadSkill(node, 1) + + local socketGroupList = build.skillsTab.skillSets[1].socketGroupList + assert.is_nil(socketGroupList[#socketGroupList].gemList[1].tamedBeastModList) + end) + end) + + describe("Calculation wiring", function() + -- Mighty Silverfist: monsterTags include both fast_movement and boss, so it can + -- never roll Haste Aura (fast_movement = 0) or Regenerates Life (boss = 0) + local beastId = "Metadata/Monsters/Quadrilla/QuadrillaBossMinion1" + + local function buildCompanionGroup(tamedBeastModList) + table.insert(build.beastList, beastId) + local gemInstance = { + nameSpec = "Companion: Mighty Silverfist", + gemId = "Metadata/Items/Gems/SkillGemSummonBeast", + level = 20, quality = 0, enabled = true, enableGlobal1 = true, enableGlobal2 = true, + count = 1, corrupted = false, corruptLevel = 0, + skillMinion = beastId, + skillMinionCalcs = beastId, + tamedBeastModList = tamedBeastModList, + } + local group = { label = "", enabled = true, gemList = { gemInstance } } + table.insert(build.skillsTab.socketGroupList, group) + build.skillsTab:ProcessSocketGroup(group) + build.mainSocketGroup = #build.skillsTab.socketGroupList + build.buildFlag = true + runCallback("OnFrame") + return gemInstance + end + + it("applies enabled beast mods to the companion minion", function() + buildCompanionGroup({ { modId = "PlayerMonsterDamageGainedAsCold1", enabled = true } }) + + local minion = build.calcsTab.mainEnv.minion + assert.is_not_nil(minion) + assert.are.equals(40, minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + end) + + it("skips disabled and unresolved beast mods", function() + local gemInstance = buildCompanionGroup({ + { modId = "PlayerMonsterDamageGainedAsCold1", enabled = false }, + { display = "Periodically unleashes Ice", enabled = true }, + }) + + local minion = build.calcsTab.mainEnv.minion + assert.is_not_nil(minion) + assert.are.equals(0, minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + + gemInstance.tamedBeastModList[1].enabled = true + build.buildFlag = true + runCallback("OnFrame") + assert.are.equals(40, build.calcsTab.mainEnv.minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + end) + + it("ignores enabled mods the selected beast can never roll", function() + buildCompanionGroup({ + { modId = "PlayerMonsterLifeRegenerationRatePercentage1", enabled = true }, -- boss = 0 + { modId = "PlayerMonsterDamageGainedAsCold1", enabled = true }, + }) + + local minion = build.calcsTab.mainEnv.minion + assert.is_not_nil(minion) + assert.are.equals(0, minion.modDB:Sum("BASE", nil, "LifeRegenPercent")) + assert.are.equals(40, minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + end) + + it("does not affect companions without beast mods", function() + buildCompanionGroup(nil) + + local minion = build.calcsTab.mainEnv.minion + assert.is_not_nil(minion) + assert.are.equals(0, minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + end) + + it("does not apply a stale beast mod list when the gem is not a Companion", function() + build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") + runCallback("OnFrame") + local srcInstance = build.skillsTab.socketGroupList[1].gemList[1] + srcInstance.tamedBeastModList = { { modId = "PlayerMonsterDamageGainedAsCold1", enabled = true } } + build.buildFlag = true + runCallback("OnFrame") + + local minion = build.calcsTab.mainEnv.minion + assert.is_not_nil(minion) + assert.are.equals(0, minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + end) + + it("applies entries beyond the fourth and creates UI rows for them", function() + buildCompanionGroup({ + { modId = "PlayerMonsterIncreasedSpeedAura1", enabled = false }, + { modId = "PlayerMonsterLifeRegenerationRatePercentage1", enabled = false }, + { modId = "PlayerMonsterAdditionalProjectiles1", enabled = false }, + { modId = "PlayerMonsterFlaskRemovalAura1", enabled = false }, + { modId = "PlayerMonsterDamageGainedAsCold1", enabled = true }, + }) + + assert.are.equals(40, build.calcsTab.mainEnv.minion.modDB:Sum("BASE", nil, "DamageGainAsCold")) + + local skillsTab = build.skillsTab + skillsTab:SetDisplayGroup(skillsTab.socketGroupList[#skillsTab.socketGroupList]) + skillsTab:UpdateBeastModSlots() + assert.is_not_nil(skillsTab.beastModSlots[6]) + local slot5 = skillsTab.beastModSlots[5] + assert.are.equals("PlayerMonsterDamageGainedAsCold1", slot5.select.list[slot5.select.selIndex].modId) + end) + end) + + describe("Re-import preservation", function() + local function companionPayload() + return { + level = 12, + equipment = { }, + skills = { { + support = false, + typeLine = "Companion: Mighty Silverfist", + properties = { { name = "Level", values = { { "17", 0 } } } }, + tamedBeastProperties = { { + name = "Monster Modifiers:\n{0}", + values = { { "[PlayerMonsterDamageGainedAsCold1|Extra Cold Damage]\n[PlayerMonsterIncreasedSpeedAura1|Haste Aura]", 0 } }, + displayMode = 3, + } }, + } }, + } + end + + it("keeps user enable choices for surviving mods when re-importing", function() + build.importTab.controls.charImportItemsClearSkills.state = true + build.importTab.controls.charImportItemsClearItems.state = false + build.importTab:ImportItemsAndSkills(companionPayload()) + runCallback("OnFrame") + + local gem = build.skillsTab.socketGroupList[1].gemList[1] + assert.are.equals(2, #gem.tamedBeastModList) + assert.True(gem.tamedBeastModList[1].enabled) + gem.tamedBeastModList[1].enabled = false + + build.importTab:ImportItemsAndSkills(companionPayload()) + runCallback("OnFrame") + + gem = build.skillsTab.socketGroupList[1].gemList[1] + assert.are.equals(2, #gem.tamedBeastModList) + assert.are.equals("PlayerMonsterDamageGainedAsCold1", gem.tamedBeastModList[1].modId) + assert.False(gem.tamedBeastModList[1].enabled) + assert.True(gem.tamedBeastModList[2].enabled) + end) + end) +end) diff --git a/src/Classes/GemTooltip.lua b/src/Classes/GemTooltip.lua index e72c8634e..4abfd5bf8 100644 --- a/src/Classes/GemTooltip.lua +++ b/src/Classes/GemTooltip.lua @@ -360,4 +360,75 @@ function GemTooltip.AddGemTooltip(tooltip, build, gemInstance, options) end end +-- Tooltip for a minion's own skill (e.g. a companion attack): the same look as a +-- player gem tooltip, but minion skills carry no gem data, tier or requirements. +-- minionSkill is the live active skill from minion.activeSkillList, whose +-- activeEffect provides the level, quality and actor level for stat evaluation. +function GemTooltip.AddMinionSkillTooltip(tooltip, build, minionSkill) + local fontSizeBig, fontSizeTitle = getFontSizes() + local activeEffect = minionSkill.activeEffect + local grantedEffect = activeEffect.grantedEffect + tooltip.center = false + tooltip.color = colorCodes.GEM + tooltip.minWidth = 400 + tooltip:AddLine(fontSizeTitle, colorCodes.GEM .. grantedEffect.name, "FONTIN SC") + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. "Minion Skill", "FONTIN SC") + -- Gem-style tag line, derived from the stat sets' base flags + local seenTags, tags = { }, { } + for _, statSet in ipairs(grantedEffect.statSets) do + for flag in pairs(statSet.baseFlags or { }) do + if flag ~= "hit" and not seenTags[flag] then + seenTags[flag] = true + table.insert(tags, flag == "area" and "AoE" or (flag:sub(1, 1):upper() .. flag:sub(2))) + end + end + end + table.sort(tags) + if tags[1] then + tooltip:AddLine(fontSizeBig, "^x7F7F7F" .. table.concat(tags, ", "), "FONTIN") + end + tooltip:AddSeparator(8) + local levelStats = grantedEffect.levels[activeEffect.level] or grantedEffect.levels[1] or { } + local isAttack = grantedEffect.skillTypes and grantedEffect.skillTypes[SkillType.Attack] + local cost + for _, res in ipairs(data.costs) do + if levelStats.cost and levelStats.cost[res.Resource] then + cost = (cost and (cost .. ", ") or "") .. res.ResourceString:gsub("{0}", string.format("%g", round(levelStats.cost[res.Resource] / res.Divisor, 2))) + end + end + if cost then + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. "Cost: ^7" .. cost, "FONTIN SC") + end + if levelStats.cooldown then + local line = colorCodes.GEMINFO .. string.format("Cooldown Time: ^7%.2f sec", levelStats.cooldown) + if levelStats.storedUses and levelStats.storedUses > 1 then + line = line .. string.format(" (%d uses)", levelStats.storedUses) + end + tooltip:AddLine(fontSizeBig, line, "FONTIN SC") + end + if isAttack then + if levelStats.attackSpeedMultiplier then + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. string.format("Attack Speed: ^7%d%% of base", levelStats.attackSpeedMultiplier + 100), "FONTIN SC") + end + if levelStats.attackTime then + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. string.format("Attack Time: ^7%.2f sec", levelStats.attackTime / 1000), "FONTIN SC") + end + if levelStats.baseMultiplier then + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. string.format("Attack Damage: ^7%g%% of base", levelStats.baseMultiplier * 100), "FONTIN SC") + end + elseif (grantedEffect.castTime or 0) > 0 then + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. string.format("Cast Time: ^7%.2f sec", grantedEffect.castTime), "FONTIN SC") + end + if levelStats.critChance then + tooltip:AddLine(fontSizeBig, colorCodes.GEMINFO .. string.format("Critical Hit Chance: ^7%.2f%%", levelStats.critChance), "FONTIN SC") + end + if grantedEffect.description then + tooltip:AddSeparator(10) + for _, line in ipairs(main:WrapString(grantedEffect.description, 16, 400)) do + tooltip:AddLine(fontSizeBig, colorCodes.GEMDESCRIPTION .. line, "FONTIN ITALIC") + end + end + addEffectStats(tooltip, build, activeEffect, grantedEffect, nil, nil) +end + return GemTooltip diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 0dcb9c59d..b1c56ae8c 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -859,6 +859,7 @@ local function snapshotSocketGroupReimportState(socketGroup, isMainGroup) skillMinionSkillCalcs = gem.skillMinionSkillCalcs, skillMinionSkillStatSetIndexLookup = gem.skillMinionSkillStatSetIndexLookup and copyTable(gem.skillMinionSkillStatSetIndexLookup), skillMinionSkillStatSetIndexLookupCalcs = gem.skillMinionSkillStatSetIndexLookupCalcs and copyTable(gem.skillMinionSkillStatSetIndexLookupCalcs), + tamedBeastModList = gem.tamedBeastModList and copyTable(gem.tamedBeastModList), enableGlobal1 = gem.enableGlobal1, enableGlobal2 = gem.enableGlobal2, } @@ -894,6 +895,23 @@ local function applyGemReimportState(gem, state) gem.skillMinionSkillCalcs = state.skillMinionSkillCalcs gem.skillMinionSkillStatSetIndexLookup = state.skillMinionSkillStatSetIndexLookup and copyTable(state.skillMinionSkillStatSetIndexLookup) gem.skillMinionSkillStatSetIndexLookupCalcs = state.skillMinionSkillStatSetIndexLookupCalcs and copyTable(state.skillMinionSkillStatSetIndexLookupCalcs) + if state.tamedBeastModList then + if gem.tamedBeastModList then + local preservedEnabled = { } + for _, entry in ipairs(state.tamedBeastModList) do + if entry.modId and entry.enabled ~= nil then + preservedEnabled[entry.modId] = entry.enabled + end + end + for _, entry in ipairs(gem.tamedBeastModList) do + if entry.modId and preservedEnabled[entry.modId] ~= nil then + entry.enabled = preservedEnabled[entry.modId] + end + end + else + gem.tamedBeastModList = copyTable(state.tamedBeastModList) + end + end gem.enableGlobal1 = state.enableGlobal1 gem.enableGlobal2 = state.enableGlobal2 end @@ -914,6 +932,28 @@ local function applySocketGroupReimportState(socketGroup, state) end end +-- Parses the "tamedBeastProperties" field on rare tamed beast Companion gems into a list of +-- { modId, display, enabled } entries. Each newline-separated line is one rolled monster mod; +-- most lead with a "[ModId|Display]" token, the rest are matched by display line. +function ImportTabClass:ParseTamedBeastProperties(tamedBeastProperties) + local data = self.build.data + local list = { } + for _, property in ipairs(tamedBeastProperties or { }) do + local text = property.values and property.values[1] and property.values[1][1] + if type(text) == "string" then + for line in text:gmatch("[^\n]+") do + local display = escapeGGGString(line) + local modId = line:match("^%[([%w_]+)|[^%]]*%]$") + if not (modId and data.tamedBeastMods[modId]) then + modId = data.tamedBeastModsByDisplay[data.normaliseBeastModLine(display)] + end + t_insert(list, { modId = modId, display = display, enabled = true }) + end + end + end + return list[1] and list or nil +end + function ImportTabClass:ImportItemsAndSkills(charData) local charItemData = charData.equipment if self.controls.charImportItemsClearItems.state then @@ -1034,6 +1074,7 @@ function ImportTabClass:ImportItemsAndSkills(charData) break end end + gemInstance.tamedBeastModList = self:ParseTamedBeastProperties(skillData.tamedBeastProperties) end gemInstance.nameSpec = self.build.data.gems[gemId].name diff --git a/src/Classes/LabelControl.lua b/src/Classes/LabelControl.lua index 2f799ece2..311bf5f99 100644 --- a/src/Classes/LabelControl.lua +++ b/src/Classes/LabelControl.lua @@ -3,15 +3,31 @@ -- Class: Label Control -- Simple text label. -- -local LabelClass = newClass("LabelControl", "Control", function(self, anchor, rect, label) +local LabelClass = newClass("LabelControl", "Control", "TooltipHost", function(self, anchor, rect, label) self.Control(anchor, rect) + self.TooltipHost() self.label = label self.width = function() return DrawStringWidth(self:GetProperty("height"), "VAR", self:GetProperty("label")) end end) -function LabelClass:Draw() +-- Labels are mouse-transparent unless a tooltip has been set on them, so they +-- never intercept hover or clicks meant for surrounding controls +function LabelClass:IsMouseOver() + if not self:IsShown() or not (self.tooltipText or self.tooltipFunc) then + return false + end + return self:IsMouseInBounds() +end + +function LabelClass:Draw(viewPort) local x, y = self:GetPos() DrawString(x, y, "LEFT", self:GetProperty("height"), "VAR", self:GetProperty("label")) -end \ No newline at end of file + if self:IsMouseOver() then + SetDrawLayer(nil, 100) + local width, height = self:GetSize() + self:DrawTooltip(x, y, width, height, viewPort) + SetDrawLayer(nil, 0) + end +end diff --git a/src/Classes/SkillListControl.lua b/src/Classes/SkillListControl.lua index 891f4374b..069360ecd 100644 --- a/src/Classes/SkillListControl.lua +++ b/src/Classes/SkillListControl.lua @@ -178,6 +178,7 @@ function SkillListClass:OnHoverKeyUp(key) elseif key == "RIGHTBUTTON" then if IsKeyDown("CTRL") then item.includeInFullDPS = not item.includeInFullDPS + self.skillsTab:SyncCompanionFullDPS(item) if item == self.skillsTab.displayGroup then self.skillsTab:SetDisplayGroup(item) end diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index 3c43bfffe..5749f8e25 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -10,6 +10,8 @@ local t_remove = table.remove local m_min = math.min local m_max = math.max +local gemTooltip = LoadModule("Classes/GemTooltip") + local groupSlotDropList = { { label = "None" }, { label = "Weapon 1", slotName = "Weapon 1" }, @@ -212,6 +214,7 @@ local SkillsTabClass = newClass("SkillsTab", "UndoHandler", "ControlHost", "Cont end self.controls.includeInFullDPS = new("CheckBoxControl", { "LEFT", self.controls.groupEnabled, "RIGHT" }, { 145, 0, 20 }, "Include in Full DPS:", function(state) self.displayGroup.includeInFullDPS = state + self:SyncCompanionFullDPS(self.displayGroup) self:AddUndoState() self.build.buildFlag = true end) @@ -282,6 +285,76 @@ will automatically apply to the skill.]] self.controls.gemCorruptHeader = new("LabelControl", {"BOTTOMLEFT", self.gemSlots[1].corruptLevel, "TOPLEFT"}, {0, -2, 0, 16}, "^7Corrupt:") self.controls.gemEnableHeader = new("LabelControl", {"BOTTOMLEFT", self.gemSlots[1].enabled, "TOPLEFT"}, {-16, -2, 0, 16}, "^7Enabled:") self.controls.gemCountHeader = new("LabelControl", {"BOTTOMLEFT", self.gemSlots[1].count, "TOPLEFT"}, {18, -2, 0, 16}, "^7Count:") + + -- Tamed beast (companion) selection and Full DPS attack list + self.anchorCompanion = new("Control", nil, {0, 0, 0, 0}) + self.anchorCompanion:SetAnchor("TOPLEFT", self.anchorGemSlots, "TOPLEFT", 0, function() + local y = 0 + for i = 1, (self.displayGroup and #self.displayGroup.gemList or 0) + 1 do + local slot = self.gemSlots[i] + y = y + ((slot and (slot.enableGlobal1:IsShown() or slot.enableGlobal2:IsShown())) and 46 or 22) + end + return y + 14 + end) + local function companionShown() + return self:GetDisplayedBeastGem() ~= nil + end + self.controls.companionBeastLabel = new("LabelControl", {"TOPLEFT", self.anchorCompanion, "TOPLEFT"}, {0, 20, 0, 16}, "^7Beast:") + self.controls.companionBeastLabel.shown = companionShown + self.controls.companionBeastSelect = new("DropDownControl", {"LEFT", self.controls.companionBeastLabel, "RIGHT"}, {4, 0, 250, 18}, nil, function(index, value) + local gemInstance = self:GetDisplayedBeastGem() + if gemInstance and value.minionId then + gemInstance.skillMinion = value.minionId + gemInstance.skillMinionCalcs = value.minionId + -- Gems resolved by id keep a "Companion: " display name; nameSpec-resolved + -- gems must keep their resolvable name (see ProcessSocketGroup) + if gemInstance.gemId and gemInstance.nameSpec and gemInstance.nameSpec:match("^Companion:") then + gemInstance.nameSpec = "Companion: "..value.label + end + -- gem slot text is only written by SetDisplayGroup, so refresh it to show the rename + self:SetDisplayGroup(self.displayGroup) + self:AddUndoState() + self.build.modFlag = true + self.build.buildFlag = true + end + end) + self.controls.companionBeastSelect.shown = companionShown + self.controls.companionBeastManage = new("ButtonControl", {"LEFT", self.controls.companionBeastSelect, "RIGHT"}, {8, 0, 110, 18}, "Manage Beasts...", function() + self.build:OpenSpectreLibrary("beast") + end) + self.controls.companionBeastManage.shown = companionShown + self.beastAttackSlots = { } + self:CreateBeastAttackSlot(1) + self.controls.beastAttackHeader = new("LabelControl", {"BOTTOMLEFT", self.beastAttackSlots[1].enabled, "TOPLEFT"}, {0, -2, 0, 16}, "^7Include in Full DPS:") + self.controls.beastAttackHeader.shown = function() + return self:GetDisplayedBeastAttacks() ~= nil + end + self.controls.beastAttackHeader.tooltipText = "Checked attacks each count as their own entry in Full DPS.\nChecking an attack includes the group in Full DPS; unchecking all attacks removes it." + + -- Tamed beast (companion) modifiers + self.anchorBeastMods = new("Control", nil, {0, 0, 0, 0}) + self.anchorBeastMods:SetAnchor("TOPLEFT", self.anchorCompanion, "TOPLEFT", 0, function() + if not self:GetDisplayedBeastGem() then + return 0 + end + local attacks = self:GetDisplayedBeastAttacks() + if not attacks then + -- beast row only (no calc data for the attack list yet) + return 44 + end + -- beast row + attacks header + one row per attack + return 62 + #attacks * 20 + 6 + end) + self.beastModSlots = { } + self:CreateBeastModSlot(1) + self.controls.beastModHeader = new("LabelControl", {"BOTTOMLEFT", self.beastModSlots[1].select, "TOPLEFT"}, {0, -2, 0, 16}, "^7Beast Modifiers:") + self.controls.beastModHeader.shown = function() + return self:GetDisplayedBeastGem() ~= nil + end + self.controls.beastModEnableHeader = new("LabelControl", {"BOTTOMLEFT", self.beastModSlots[1].enabled, "TOPLEFT"}, {-16, -2, 0, 16}, "^7Enabled:") + self.controls.beastModEnableHeader.shown = function() + return self:GetDisplayedBeastGem() ~= nil + end end) function SkillsTabClass:GetCorruptIndex(gemInstance) @@ -386,6 +459,16 @@ function SkillsTabClass:LoadSkill(node, skillSetId) for _, map in ipairs(child) do gemInstance.skillMinionSkillStatSetIndexLookupCalcs[child.attrib.grantedEffect][tonumber(map.attrib.skillIndex)] = tonumber(map.attrib.statSetIndex) end + elseif child.elem == "TamedBeastMod" then + gemInstance.tamedBeastModList = gemInstance.tamedBeastModList or { } + t_insert(gemInstance.tamedBeastModList, { + modId = child.attrib.modId, + display = child.attrib.display, + enabled = child.attrib.enabled == "true", + }) + elseif child.elem == "FullDPSMinionSkill" and child.attrib.skillId then + gemInstance.fullDPSMinionSkills = gemInstance.fullDPSMinionSkills or { } + gemInstance.fullDPSMinionSkills[child.attrib.skillId] = true end end @@ -537,6 +620,26 @@ function SkillsTabClass:Save(xml) t_insert(gemInfo, minionSkillStatSetIndexLookupCalcs) end end + if gemInstance.tamedBeastModList then + for _, beastMod in ipairs(gemInstance.tamedBeastModList) do + t_insert(gemInfo, { elem = "TamedBeastMod", attrib = { + modId = beastMod.modId, + display = beastMod.display, + enabled = tostring(beastMod.enabled), + } } ) + end + end + if gemInstance.fullDPSMinionSkills then + -- sorted for deterministic build XML + local skillIds = { } + for skillId in pairs(gemInstance.fullDPSMinionSkills) do + t_insert(skillIds, skillId) + end + table.sort(skillIds) + for _, skillId in ipairs(skillIds) do + t_insert(gemInfo, { elem = "FullDPSMinionSkill", attrib = { skillId = skillId } } ) + end + end t_insert(node, gemInfo) end t_insert(child, node) @@ -603,6 +706,8 @@ function SkillsTabClass:Draw(viewPort, inputEvents) end self:UpdateGemSlots() + self:UpdateBeastAttackSlots() + self:UpdateBeastModSlots() self:DrawControls(viewPort) end @@ -787,6 +892,13 @@ function SkillsTabClass:CreateGemSlot(index) gemInstance.gemId = gemId gemInstance.skillId = nil self:ProcessSocketGroup(self.displayGroup) + -- Beast mods only exist on Companion gems; clear them when the gem becomes anything else + if gemInstance.tamedBeastModList then + local grantedEffect = gemInstance.gemData and gemInstance.gemData.grantedEffect + if not (grantedEffect and grantedEffect.minionList and grantedEffect.name:match("^Companion")) then + gemInstance.tamedBeastModList = nil + end + end -- New gems need to be constrained by ProcessGemLevel gemInstance.level = self:ProcessGemLevel(gemInstance.gemData) gemInstance.naturalMaxLevel = gemInstance.level @@ -1095,6 +1207,388 @@ function SkillsTabClass:CreateGemSlot(index) self.controls["gemSlot"..index.."EnableGlobal2"] = slot.enableGlobal2 end +-- Returns the group's Companion gem, if any (only companions carry tamed beast mods) +function SkillsTabClass:GetBeastGemForGroup(socketGroup) + if not socketGroup then + return + end + for _, gemInstance in ipairs(socketGroup.gemList) do + local grantedEffect = gemInstance.gemData and gemInstance.gemData.grantedEffect + if grantedEffect and grantedEffect.minionList and grantedEffect.name:match("^Companion") then + return gemInstance + end + end +end + +function SkillsTabClass:GetDisplayedBeastGem() + return self:GetBeastGemForGroup(self.displayGroup) +end + +-- Returns the group companion's attack list (minion.activeSkillList), its owning +-- active skill and the gem instance; the list is nil when no calc data is available +-- (group disabled, no beast, or no calc pass since the last edit) +function SkillsTabClass:GetBeastAttacksForGroup(socketGroup) + local gemInstance = self:GetBeastGemForGroup(socketGroup) + if not gemInstance or not socketGroup.displaySkillList then + return nil, nil, gemInstance + end + for _, activeSkill in ipairs(socketGroup.displaySkillList) do + if activeSkill.activeEffect.srcInstance == gemInstance and activeSkill.minion and activeSkill.minion.activeSkillList then + return activeSkill.minion.activeSkillList, activeSkill, gemInstance + end + end + return nil, nil, gemInstance +end + +function SkillsTabClass:GetDisplayedBeastAttacks() + return self:GetBeastAttacksForGroup(self.displayGroup) +end + +-- Keep the companion attack selection in step with the group's Include in Full DPS +-- state: enabling it selects the first attack, disabling it clears the selection +function SkillsTabClass:SyncCompanionFullDPS(socketGroup) + local attacks, _, gemInstance = self:GetBeastAttacksForGroup(socketGroup) + if not gemInstance then + return + end + if not socketGroup.includeInFullDPS then + gemInstance.fullDPSMinionSkills = nil + elseif attacks and attacks[1] then + gemInstance.fullDPSMinionSkills = { [attacks[1].activeEffect.grantedEffect.id] = true } + end +end + +-- The active attack: the one the sidebar's minion skill dropdown selects, which is +-- what counts for Full DPS when no explicit per-attack selection has been made +function SkillsTabClass:GetDisplayedBeastActiveAttackIndex(gemInstance, attacks) + return m_max(m_min(gemInstance.skillMinionSkill or 1, #attacks), 1) +end + +function SkillsTabClass:CreateBeastAttackSlot(index) + local slot = { } + self.beastAttackSlots[index] = slot + + local function isRowShown() + local attacks = self:GetDisplayedBeastAttacks() + return attacks ~= nil and index <= #attacks + end + + -- Include this attack in Full DPS + slot.enabled = new("CheckBoxControl", nil, {0, 0, 18}, nil, function(state) + local attacks, activeSkill, gemInstance = self:GetDisplayedBeastAttacks() + local minionSkill = attacks and attacks[index] + if not minionSkill then + return + end + -- Build a new table every time: undo snapshots share nested tables (shallow + -- copyTable in CreateUndoState), so in-place mutation would corrupt them + local newSelection = { } + if gemInstance.fullDPSMinionSkills then + for skillId, value in pairs(gemInstance.fullDPSMinionSkills) do + newSelection[skillId] = value + end + elseif self.displayGroup.includeInFullDPS then + -- Materialize the legacy default: only the active attack counts + local activeIndex = self:GetDisplayedBeastActiveAttackIndex(gemInstance, attacks) + newSelection[attacks[activeIndex].activeEffect.grantedEffect.id] = true + end + newSelection[minionSkill.activeEffect.grantedEffect.id] = state or nil + -- The selection and the group's Include in Full DPS state move together: + -- checking any attack turns it on, unchecking the last one turns it off + if next(newSelection) then + gemInstance.fullDPSMinionSkills = newSelection + self.displayGroup.includeInFullDPS = true + else + gemInstance.fullDPSMinionSkills = nil + self.displayGroup.includeInFullDPS = false + end + -- the group checkbox state is only refreshed by SetDisplayGroup, so mirror it here + self.controls.includeInFullDPS.state = self.displayGroup.includeInFullDPS and self.displayGroup.enabled + self:AddUndoState() + self.build.buildFlag = true + end) + if index == 1 then + slot.enabled:SetAnchor("TOPLEFT", self.anchorCompanion, "TOPLEFT", 0, 62) + else + slot.enabled:SetAnchor("TOPLEFT", self.beastAttackSlots[index - 1].enabled, "BOTTOMLEFT", 0, 2) + end + slot.enabled.shown = isRowShown + slot.enabled.tooltipText = "Include this attack as its own Full DPS entry." + self.controls["beastAttackSlot"..index.."Enable"] = slot.enabled + + -- Attack name; hovering it shows the skill's data, gem tooltip style + slot.label = new("LabelControl", {"LEFT", slot.enabled, "RIGHT"}, {6, 0, 0, 16}, function() + local attacks = self:GetDisplayedBeastAttacks() + local minionSkill = attacks and attacks[index] + if not minionSkill then + return "" + end + return "^7"..minionSkill.activeEffect.grantedEffect.name + end) + slot.label.shown = isRowShown + slot.label.tooltipFunc = function(tooltip) + local attacks = self:GetDisplayedBeastAttacks() + local minionSkill = attacks and attacks[index] + if tooltip:CheckForUpdate(self.build.outputRevision, self.displayGroup, minionSkill) and minionSkill then + gemTooltip.AddMinionSkillTooltip(tooltip, self.build, minionSkill) + end + end + self.controls["beastAttackSlot"..index.."Label"] = slot.label +end + +-- Update the companion controls to reflect the currently displayed socket group +function SkillsTabClass:UpdateBeastAttackSlots() + local gemInstance = self:GetDisplayedBeastGem() + if not gemInstance then + return + end + -- Beast selection list, sourced from the build's tamed beast library + wipeTable(self.controls.companionBeastSelect.list) + for _, minionId in ipairs(self.build.beastList or { }) do + local minion = self.build.data.minions[minionId] + if minion then + t_insert(self.controls.companionBeastSelect.list, { label = minion.name, minionId = minionId }) + end + end + if not self.controls.companionBeastSelect.list[1] then + t_insert(self.controls.companionBeastSelect.list, { label = "" }) + end + self.controls.companionBeastSelect:SelByValue(gemInstance.skillMinion, "minionId") + self.controls.companionBeastSelect.enabled = self.controls.companionBeastSelect.list[1].minionId ~= nil + -- Attack rows: create on demand, like gem and beast mod slots + local attacks = self:GetDisplayedBeastAttacks() + for index = 1, (attacks and #attacks or 0) do + if not self.beastAttackSlots[index] then + self:CreateBeastAttackSlot(index) + end + end + if not attacks then + return + end + -- Checkbox states: nothing counts while the group is out of Full DPS; with it + -- on, the explicit selection governs when any of its ids matches this beast's + -- attacks, otherwise the legacy default (only the active attack counts) + local includeInFullDPS = self.displayGroup and self.displayGroup.includeInFullDPS + local selection = gemInstance.fullDPSMinionSkills + local anyMatch = false + if selection then + for _, minionSkill in ipairs(attacks) do + if selection[minionSkill.activeEffect.grantedEffect.id] then + anyMatch = true + break + end + end + end + local activeIndex = self:GetDisplayedBeastActiveAttackIndex(gemInstance, attacks) + for index, slot in ipairs(self.beastAttackSlots) do + local minionSkill = attacks[index] + if minionSkill then + if not includeInFullDPS then + slot.enabled.state = false + elseif anyMatch then + slot.enabled.state = selection[minionSkill.activeEffect.grantedEffect.id] or false + else + slot.enabled.state = index == activeIndex + end + end + end +end + +function SkillsTabClass:GetBeastModDropList() + if not self.beastModDropList then + local sorted = { } + local nameCount = { } + for modId, beastMod in pairs(self.build.data.tamedBeastMods) do + t_insert(sorted, { modId = modId, beastMod = beastMod }) + nameCount[beastMod.name] = (nameCount[beastMod.name] or 0) + 1 + end + table.sort(sorted, function(a, b) + if a.beastMod.name ~= b.beastMod.name then + return a.beastMod.name < b.beastMod.name + end + return (a.beastMod.tier or 0) < (b.beastMod.tier or 0) + end) + local list = { { label = "" } } + for _, entry in ipairs(sorted) do + local label = entry.beastMod.name + if entry.beastMod.tier and nameCount[entry.beastMod.name] > 1 then + label = label .. " (Tier " .. entry.beastMod.tier .. ")" + end + t_insert(list, { label = label, modId = entry.modId, beastMod = entry.beastMod }) + end + self.beastModDropList = list + end + return self.beastModDropList +end + +function SkillsTabClass:CreateBeastModSlot(index) + local slot = { } + self.beastModSlots[index] = slot + + local function getEntry() + local gemInstance = self:GetDisplayedBeastGem() + return gemInstance and gemInstance.tamedBeastModList and gemInstance.tamedBeastModList[index], gemInstance + end + + local function isRowShown() + local gemInstance = self:GetDisplayedBeastGem() + return gemInstance ~= nil and index <= (gemInstance.tamedBeastModList and #gemInstance.tamedBeastModList or 0) + 1 + end + + -- Remove modifier + slot.delete = new("ButtonControl", nil, {0, 0, 20, 20}, "x", function() + local entry, gemInstance = getEntry() + if entry then + t_remove(gemInstance.tamedBeastModList, index) + self:AddUndoState() + self.build.buildFlag = true + end + end) + if index == 1 then + slot.delete:SetAnchor("TOPLEFT", self.anchorBeastMods, "TOPLEFT", 0, 20) + else + slot.delete:SetAnchor("TOPLEFT", self.beastModSlots[index - 1].delete, "BOTTOMLEFT", 0, 2) + end + slot.delete.shown = isRowShown + slot.delete.enabled = function() + return getEntry() ~= nil + end + slot.delete.tooltipText = "Remove this modifier." + self.controls["beastModSlot"..index.."Delete"] = slot.delete + + -- Modifier selection + slot.select = new("DropDownControl", {"LEFT", slot.delete, "RIGHT"}, {2, 0, 300, 20}, self:GetBeastModDropList(), function(indexSel, value) + local entry, gemInstance = getEntry() + if not gemInstance then + return + end + gemInstance.tamedBeastModList = gemInstance.tamedBeastModList or { } + if value.modId then + if entry then + entry.modId = value.modId + entry.display = nil + else + gemInstance.tamedBeastModList[index] = { modId = value.modId, enabled = true } + end + elseif entry then + t_remove(gemInstance.tamedBeastModList, index) + end + self:AddUndoState() + self.build.buildFlag = true + end) + slot.select.shown = isRowShown + slot.select.tooltipFunc = function(tooltip, mode, indexSel, value) + if tooltip:CheckForUpdate(self.build.outputRevision, value, self.displayGroup) then + if mode == "OUT" or not value or not value.beastMod then + return + end + for _, line in ipairs(value.beastMod.statDescriptions) do + if value.beastMod.modList[1] then + tooltip:AddLine(16, colorCodes.MAGIC..line) + else + local line = colorCodes.UNSUPPORTED..line + line = main.notSupportedModTooltips and (line .. main.notSupportedTooltipText) or line + tooltip:AddLine(16, line) + end + end + local entry, gemInstance = getEntry() + if not gemInstance then + return + end + local minion = gemInstance.skillMinion and self.build.data.minions[gemInstance.skillMinion] + if minion and not self.build.data.beastModCanSpawn(value.beastMod, minion.monsterTags) then + tooltip:AddLine(16, "^1Cannot naturally spawn on "..minion.name.." and will be ignored") + end + local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) + if calcFunc then + -- Trial-swap the entry for the compare, restoring both the entry and the + -- list reference so a hover can never leave state behind + local storedList = gemInstance.tamedBeastModList + gemInstance.tamedBeastModList = storedList or { } + local storedEntry = gemInstance.tamedBeastModList[index] + gemInstance.tamedBeastModList[index] = { modId = value.modId, enabled = true } + local output = calcFunc() + gemInstance.tamedBeastModList[index] = storedEntry + gemInstance.tamedBeastModList = storedList + tooltip:AddSeparator(10) + self.build:AddStatComparesToTooltip(tooltip, calcBase, output, "^7Selecting this modifier will give you:") + end + end + end + self.controls["beastModSlot"..index.."Select"] = slot.select + + -- Enable modifier + slot.enabled = new("CheckBoxControl", {"LEFT", slot.select, "RIGHT"}, {18, 0, 18}, nil, function(state) + local entry = getEntry() + if entry then + entry.enabled = state + self:AddUndoState() + self.build.buildFlag = true + end + end) + slot.enabled.shown = function() + return getEntry() ~= nil + end + slot.enabled.tooltipFunc = function(tooltip) + if tooltip:CheckForUpdate(self.build.outputRevision, self.displayGroup) then + local entry = getEntry() + if entry and entry.modId then + local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) + if calcFunc then + entry.enabled = not entry.enabled + local output = calcFunc() + entry.enabled = not entry.enabled + self.build:AddStatComparesToTooltip(tooltip, calcBase, output, entry.enabled and "^7Disabling this modifier will give you:" or "^7Enabling this modifier will give you:") + end + end + end + end + self.controls["beastModSlot"..index.."Enable"] = slot.enabled + + -- Per-row problem indicator: an imported entry the data doesn't know (stays toggleable + -- and removable, but applies nothing), or a known mod the selected beast's tags can + -- never roll (ignored by calcs, see CalcPerform) + slot.unresolved = new("LabelControl", {"LEFT", slot.enabled, "RIGHT"}, {8, 0, 0, 16}, function() + local entry, gemInstance = getEntry() + if not entry then + return "" + end + local beastMod = entry.modId and self.build.data.tamedBeastMods[entry.modId] + if not beastMod then + local display = entry.display or entry.modId + return display and ("^1Unrecognised: ^7"..display) or "" + end + local minion = gemInstance.skillMinion and self.build.data.minions[gemInstance.skillMinion] + if minion and not self.build.data.beastModCanSpawn(beastMod, minion.monsterTags) then + return "^1Cannot spawn on ^7"..minion.name + end + return "" + end) + self.controls["beastModSlot"..index.."Unresolved"] = slot.unresolved +end + +-- Update the beast mod slot controls to reflect the currently displayed socket group's companion +function SkillsTabClass:UpdateBeastModSlots() + local gemInstance = self:GetDisplayedBeastGem() + if not gemInstance then + return + end + -- Create slots on demand, like gem slots: one row per entry plus an empty row to add more + for index = 1, (gemInstance.tamedBeastModList and #gemInstance.tamedBeastModList or 0) + 1 do + if not self.beastModSlots[index] then + self:CreateBeastModSlot(index) + end + end + for index, slot in ipairs(self.beastModSlots) do + local entry = gemInstance.tamedBeastModList and gemInstance.tamedBeastModList[index] + slot.select.selIndex = 1 + if entry and entry.modId then + slot.select:SelByValue(entry.modId, "modId") + end + slot.enabled.state = entry and entry.enabled or false + end +end + -- Update the gem slot controls to reflect the currently displayed socket group function SkillsTabClass:UpdateGemSlots() if not self.displayGroup then diff --git a/src/Data/Spectres.lua b/src/Data/Spectres.lua index d1ad258ea..48f0e1a4f 100644 --- a/src/Data/Spectres.lua +++ b/src/Data/Spectres.lua @@ -20584,7 +20584,7 @@ minions["Metadata/Monsters/LeagueIncursionNew/Thaumaturge/VaalThaumaturgeSpear"] minions["Metadata/Monsters/LeagueIncursionNew/MiniBosses/SoulCoreQuadrillaBoss/SoulCoreQuadrillaMinion"] = { name = "Quadrilla Sergeant", - monsterTags = { "2HBluntStone_onhit_audio", "beast", "fast_movement", "humanoid", "incursion_unique_quadrilla", "not_dex", "not_int", "red_blood", "very_fast_movement", }, + monsterTags = { "2HBluntStone_onhit_audio", "beast", "fast_movement", "humanoid", "incursion_unique_quadrilla", "not_dex", "not_int", "red_blood", "very_fast_movement", "boss", }, life = 2.5, baseDamageIgnoresAttackSpeed = true, armour = 0.66, @@ -20626,7 +20626,7 @@ minions["Metadata/Monsters/LeagueIncursionNew/MiniBosses/SoulCoreQuadrillaBoss/S minions["Metadata/Monsters/LeagueIncursionNew/MiniBosses/IncursionChainedBeastBoss/ChainedBeastBossMinion_"] = { name = "Unchained Beast", - monsterTags = { "beast", "Claw_onhit_audio", "incursion_unique_chained_beast", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", }, + monsterTags = { "beast", "Claw_onhit_audio", "incursion_unique_chained_beast", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", "boss", }, life = 2.5, baseDamageIgnoresAttackSpeed = true, armour = 0.66, @@ -20740,7 +20740,7 @@ minions["Metadata/Monsters/LeagueExpeditionNew/Expedition2/HumanoidFaction/VaalF minions["Metadata/Monsters/CrowBell/CrowBellBossMinion1"] = { name = "The Crowbell", - monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "red_blood", }, + monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -20815,7 +20815,7 @@ minions["Metadata/Monsters/CrowBell/CrowBellBossMinion1"] = { minions["Metadata/Monsters/CrowBell/CrowBellBossMinion2"] = { name = "The Black Crow", - monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "red_blood", }, + monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -20890,7 +20890,7 @@ minions["Metadata/Monsters/CrowBell/CrowBellBossMinion2"] = { minions["Metadata/Monsters/MudBurrower/MudBurrowerHeadBossMinion1"] = { name = "The Devourer", - monsterTags = { "beast", "Beast_onhit_audio", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", }, + monsterTags = { "beast", "Beast_onhit_audio", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -20953,7 +20953,7 @@ minions["Metadata/Monsters/MudBurrower/MudBurrowerHeadBossMinion1"] = { minions["Metadata/Monsters/MudBurrower/MudBurrowerHeadBossMinion2"] = { name = "Gorian, the Moving Earth", - monsterTags = { "beast", "Beast_onhit_audio", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", }, + monsterTags = { "beast", "Beast_onhit_audio", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21016,7 +21016,7 @@ minions["Metadata/Monsters/MudBurrower/MudBurrowerHeadBossMinion2"] = { minions["Metadata/Monsters/ChimeraWetlandsBoss/ChimeraWetlandsBossMinion1"] = { name = "Xyclucian, the Chimera", - monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21086,7 +21086,7 @@ minions["Metadata/Monsters/ChimeraWetlandsBoss/ChimeraWetlandsBossMinion1"] = { minions["Metadata/Monsters/ChimeraWetlandsBoss/ChimeraWetlandsBossMinion2"] = { name = "Xilozoma, the Maw-Beast", - monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21156,7 +21156,7 @@ minions["Metadata/Monsters/ChimeraWetlandsBoss/ChimeraWetlandsBossMinion2"] = { minions["Metadata/Monsters/Ultimatum/ChimeraUltimatumBossMinion1"] = { name = "Uxmal, the Beastlord", - monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21220,7 +21220,7 @@ minions["Metadata/Monsters/Ultimatum/ChimeraUltimatumBossMinion1"] = { minions["Metadata/Monsters/Ultimatum/ChimeraUltimatumBossMinion2"] = { name = "Gressor-Kul, the Apex", - monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Claw_onhit_audio", "flying", "mammal_beast", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21284,7 +21284,7 @@ minions["Metadata/Monsters/Ultimatum/ChimeraUltimatumBossMinion2"] = { minions["Metadata/Monsters/Bird2/MutantBird2Minion1"] = { name = "Scourge of the Skies", - monsterTags = { "beast", "Beast_onhit_audio", "flying", "red_blood", "very_slow_movement", }, + monsterTags = { "beast", "Beast_onhit_audio", "flying", "red_blood", "very_slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21368,7 +21368,7 @@ minions["Metadata/Monsters/Bird2/MutantBird2Minion1"] = { minions["Metadata/Monsters/Bird2/MutantBird2Minion2"] = { name = "Chetza, the Feathered Plague", - monsterTags = { "beast", "Beast_onhit_audio", "flying", "red_blood", "very_slow_movement", }, + monsterTags = { "beast", "Beast_onhit_audio", "flying", "red_blood", "very_slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21452,7 +21452,7 @@ minions["Metadata/Monsters/Bird2/MutantBird2Minion2"] = { minions["Metadata/Monsters/HyenaMonster/RathbreakerBossMinion1"] = { name = "Rathbreaker", - monsterTags = { "2HSharpMetal_onhit_audio", "beast", "fast_movement", "mammal_beast", "melee", "physical_affinity", "red_blood", }, + monsterTags = { "2HSharpMetal_onhit_audio", "beast", "fast_movement", "mammal_beast", "melee", "physical_affinity", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21501,7 +21501,7 @@ minions["Metadata/Monsters/HyenaMonster/RathbreakerBossMinion1"] = { minions["Metadata/Monsters/HyenaMonster/RathbreakerBossMinion2"] = { name = "Caedron, the Hyena Lord", - monsterTags = { "2HSharpMetal_onhit_audio", "beast", "fast_movement", "mammal_beast", "melee", "physical_affinity", "red_blood", }, + monsterTags = { "2HSharpMetal_onhit_audio", "beast", "fast_movement", "mammal_beast", "melee", "physical_affinity", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21550,7 +21550,7 @@ minions["Metadata/Monsters/HyenaMonster/RathbreakerBossMinion2"] = { minions["Metadata/Monsters/Quadrilla/QuadrillaBossMinion1"] = { name = "Mighty Silverfist", - monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21595,7 +21595,7 @@ minions["Metadata/Monsters/Quadrilla/QuadrillaBossMinion1"] = { minions["Metadata/Monsters/Quadrilla/QuadrillaBossMinion2"] = { name = "Zekoa, the Headcrusher", - monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21640,7 +21640,7 @@ minions["Metadata/Monsters/Quadrilla/QuadrillaBossMinion2"] = { minions["Metadata/Monsters/Quadrilla/IcyQuadrillaBossMinion1"] = { name = "The Abominable Yeti", - monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21692,7 +21692,7 @@ minions["Metadata/Monsters/Quadrilla/IcyQuadrillaBossMinion1"] = { minions["Metadata/Monsters/Quadrilla/IcyQuadrillaBossMinion2"] = { name = "The Frostborn Fiend", - monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "mammal_beast", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21744,7 +21744,7 @@ minions["Metadata/Monsters/Quadrilla/IcyQuadrillaBossMinion2"] = { minions["Metadata/Monsters/GreatWhiteOne/GreatWhiteOneMinion1"] = { name = "Great White One", - monsterTags = { "beast", "fast_movement", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", }, + monsterTags = { "beast", "fast_movement", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21818,7 +21818,7 @@ minions["Metadata/Monsters/GreatWhiteOne/GreatWhiteOneMinion1"] = { minions["Metadata/Monsters/GreatWhiteOne/GreatWhiteOneMinion2"] = { name = "The Sandstrider", - monsterTags = { "beast", "fast_movement", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", }, + monsterTags = { "beast", "fast_movement", "MonsterBlunt_onhit_audio", "not_dex", "not_int", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21892,7 +21892,7 @@ minions["Metadata/Monsters/GreatWhiteOne/GreatWhiteOneMinion2"] = { minions["Metadata/Monsters/Goblins/Beast/ArenaBeastBossMinion1_"] = { name = "The Ravenous Fang", - monsterTags = { "beast", "Claw_onhit_audio", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", }, + monsterTags = { "beast", "Claw_onhit_audio", "mammal_beast", "medium_movement", "not_dex", "not_int", "red_blood", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -21961,7 +21961,7 @@ minions["Metadata/Monsters/Goblins/Beast/ArenaBeastBossMinion1_"] = { minions["Metadata/Monsters/ChaosGodOwlBoss/ChaosGodOwlBossMinion"] = { name = "Bahlak, the Sky Seer", - monsterTags = { "beast", "Beast_onhit_audio", "flying", "not_str", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Beast_onhit_audio", "flying", "not_str", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22025,7 +22025,7 @@ minions["Metadata/Monsters/ChaosGodOwlBoss/ChaosGodOwlBossMinion"] = { minions["Metadata/Monsters/ChaosGodOwlBoss/IcyOwlBossMinion1"] = { name = "Rakkar, the Frozen Talon", - monsterTags = { "beast", "Beast_onhit_audio", "flying", "not_str", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Beast_onhit_audio", "flying", "not_str", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22088,7 +22088,7 @@ minions["Metadata/Monsters/ChaosGodOwlBoss/IcyOwlBossMinion1"] = { minions["Metadata/Monsters/ChaosGodOwlBoss/IcyOwlBossMinion2"] = { name = "Thraeven, Wing of Winter", - monsterTags = { "beast", "Beast_onhit_audio", "flying", "not_str", "red_blood", "slow_movement", }, + monsterTags = { "beast", "Beast_onhit_audio", "flying", "not_str", "red_blood", "slow_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22151,7 +22151,7 @@ minions["Metadata/Monsters/ChaosGodOwlBoss/IcyOwlBossMinion2"] = { minions["Metadata/Monsters/MarakethSanctumTrial/Boss/Shakari/ShakariMinion1_"] = { name = "Ashar, the Sand Mother", - monsterTags = { "beast", "fast_movement", "insect", "MonsterStab_onhit_audio", "not_dex", "not_int", "red_blood", "sanctum_monster", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "insect", "MonsterStab_onhit_audio", "not_dex", "not_int", "red_blood", "sanctum_monster", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22203,7 +22203,7 @@ minions["Metadata/Monsters/MarakethSanctumTrial/Boss/Shakari/ShakariMinion1_"] = minions["Metadata/Monsters/MarakethSanctumTrial/Boss/Shakari/ShakariMinion2"] = { name = "Karash, The Dune Dweller", - monsterTags = { "beast", "fast_movement", "insect", "MonsterStab_onhit_audio", "not_dex", "not_int", "red_blood", "sanctum_monster", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "insect", "MonsterStab_onhit_audio", "not_dex", "not_int", "red_blood", "sanctum_monster", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22255,7 +22255,7 @@ minions["Metadata/Monsters/MarakethSanctumTrial/Boss/Shakari/ShakariMinion2"] = minions["Metadata/Monsters/Goblins/Beast/FireBeastBoss/FireBeastBossMinion1"] = { name = "Vornas, the Fell Flame", - monsterTags = { "beast", "Claw_onhit_audio", "fast_movement", "fire", "mammal_beast", "not_dex", "not_int", }, + monsterTags = { "beast", "Claw_onhit_audio", "fast_movement", "fire", "mammal_beast", "not_dex", "not_int", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22314,7 +22314,7 @@ minions["Metadata/Monsters/Goblins/Beast/FireBeastBoss/FireBeastBossMinion1"] = minions["Metadata/Monsters/Goblins/Beast/FireBeastBoss/FireBeastBossMinion2"] = { name = "Morvak, the Infernal", - monsterTags = { "beast", "Claw_onhit_audio", "fast_movement", "fire", "mammal_beast", "not_dex", "not_int", }, + monsterTags = { "beast", "Claw_onhit_audio", "fast_movement", "fire", "mammal_beast", "not_dex", "not_int", "boss", }, extraFlags = { recommendedBeast = true, }, @@ -22373,7 +22373,7 @@ minions["Metadata/Monsters/Goblins/Beast/FireBeastBoss/FireBeastBossMinion2"] = minions["Metadata/Monsters/MarakethSanctumTrial/Boss/Shakari/ShakariDuoMinion"] = { name = "Akthi, the Final Sting", - monsterTags = { "beast", "fast_movement", "insect", "MonsterStab_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", }, + monsterTags = { "beast", "fast_movement", "insect", "MonsterStab_onhit_audio", "not_dex", "not_int", "red_blood", "very_fast_movement", "boss", }, extraFlags = { recommendedBeast = true, }, diff --git a/src/Data/TamedBeastMods.lua b/src/Data/TamedBeastMods.lua new file mode 100644 index 000000000..09ca89f35 --- /dev/null +++ b/src/Data/TamedBeastMods.lua @@ -0,0 +1,1097 @@ +-- This file is automatically generated, do not edit! +-- Path of Building +-- +-- Tamed Beast (Companion) Modifier Data +-- Monster data (c) Grinding Gear Games + +local mods, mod, flag = ... + +mods["PlayerMonsterDamageGainedAsFire1"] = { + name = "Extra Fire Damage", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "fire_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster Gains 40% of damage as extra Fire damage.", + }, + modList = { + mod("DamageGainAsFire", "BASE", 40, 0, 0), -- PlayerMonsterDamageGainedAsFire1 [non_skill_base_all_damage_%_to_gain_as_fire = 40] + }, +} + +mods["PlayerMonsterDamageGainedAsCold1"] = { + name = "Extra Cold Damage", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "cold_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster Gains 40% of damage as extra Cold damage.", + }, + modList = { + mod("DamageGainAsCold", "BASE", 40, 0, 0), -- PlayerMonsterDamageGainedAsCold1 [non_skill_base_all_damage_%_to_gain_as_cold = 40] + }, +} + +mods["PlayerMonsterDamageGainedAsLightning1"] = { + name = "Extra Lightning Damage", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "lightning_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster Gains 40% of damage as extra Lightning damage.", + }, + modList = { + mod("DamageGainAsLightning", "BASE", 40, 0, 0), -- PlayerMonsterDamageGainedAsLightning1 [non_skill_base_all_damage_%_to_gain_as_lightning = 40] + }, +} + +mods["PlayerMonsterIncreasedSpeed1"] = { + name = "Hasted", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "fast_movement", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has 30% increased Attack, Cast and Movement speed.", + }, + modList = { + mod("Speed", "INC", 30, 0, 0), -- PlayerMonsterIncreasedSpeed1 [attack_and_cast_speed_+% = 30] + mod("MovementSpeed", "INC", 30, 0, 0), -- PlayerMonsterIncreasedSpeed1 [base_movement_velocity_+% = 30] + }, +} + +mods["PlayerMonsterCriticalStrikeChance1"] = { + name = "Extra Crits", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has 300% increased chance to Critically Hit.", + }, + modList = { + mod("CritChance", "INC", 300, 0, 0), -- PlayerMonsterCriticalStrikeChance1 [critical_strike_chance_+% = 300] + }, +} + +mods["PlayerMonsterStunDamageIncrease1"] = { + name = "Stuns", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "melee", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has 100% increased Stun buildup.", + }, + modList = { + -- PlayerMonsterStunDamageIncrease1 [hit_damage_stun_multiplier_+% = 100] + }, +} + +mods["PlayerMonsterExtraArmour1"] = { + name = "Armoured", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "armour", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster gains extra Armour based off of their Strength.", + }, + modList = { + -- PlayerMonsterExtraArmour1 [monster_additional_strength_ratio_%_for_armour = 100] + }, +} + +mods["PlayerMonsterExtraEvasion1"] = { + name = "Evasive", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "evasion", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster gains extra Evasion based off of their Dexterity.", + }, + modList = { + -- PlayerMonsterExtraEvasion1 [monster_additional_dexterity_ratio_%_for_evasion = 100] + }, +} + +mods["PlayerMonsterExtraEnergyShield1"] = { + name = "Extra Energy Shield", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "energy_shield", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster gains 25% of Maximum life as added Energy Shield.", + }, + modList = { + -- PlayerMonsterExtraEnergyShield1 [base_maximum_life_%_to_gain_as_total_energy_shield = 25] + }, +} + +mods["PlayerMonsterAlwaysPoison1"] = { + name = "Always Poisons", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "physical_affinity", weight = 1 }, + { tag = "chaos_affinity", weight = 1 }, + { tag = "default", weight = 0 }, + }, + statDescriptions = { + }, + modList = { + mod("PoisonChance", "BASE", 100, 0, 0), -- PlayerMonsterAlwaysPoison1 [global_poison_on_hit = 1] + }, +} + +mods["PlayerMonsterAlwaysBleed1"] = { + name = "Always Bleeds", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "physical_affinity", weight = 1 }, + { tag = "default", weight = 0 }, + }, + statDescriptions = { + }, + modList = { + mod("BleedChance", "BASE", 100, 0, 0), -- PlayerMonsterAlwaysBleed1 [global_bleed_on_hit = 1] + }, +} + +mods["PlayerMonsterBurningGroundOnDeath1"] = { + name = "Periodically unleashes Fire", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "sanctum_monster", weight = 0 }, + { tag = "titan_boss", weight = 0 }, + { tag = "fire_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterChilledGroundOnDeath1"] = { + name = "Periodically unleashes Ice", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "titan_boss", weight = 0 }, + { tag = "cold_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterShockedGroundOnDeath1"] = { + name = "Periodically unleashes Lightning", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "titan_boss", weight = 0 }, + { tag = "lightning_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterStunResilience1"] = { + name = "Stun Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has 250% increased Stun Threshold.", + }, + modList = { + mod("StunThreshold", "INC", 250, 0, 0), -- PlayerMonsterStunResilience1 [stun_threshold_+% = 250] + }, +} + +mods["PlayerMonsterFireResistance1"] = { + name = "Fire Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "fire_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has +50% to Fire Resistance and +10% to Maximum Fire Resistance.", + }, + modList = { + mod("FireResist", "BASE", 50, 0, 0), -- PlayerMonsterFireResistance1 [base_fire_damage_resistance_% = 50] + mod("FireResistMax", "BASE", 10, 0, 0), -- PlayerMonsterFireResistance1 [base_maximum_fire_damage_resistance_% = 10] + }, +} + +mods["PlayerMonsterColdResistance1"] = { + name = "Cold Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "cold_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has +50% to Cold Resistance and +10% to Maximum Cold Resistance.", + }, + modList = { + mod("ColdResist", "BASE", 50, 0, 0), -- PlayerMonsterColdResistance1 [base_cold_damage_resistance_% = 50] + mod("ColdResistMax", "BASE", 10, 0, 0), -- PlayerMonsterColdResistance1 [base_maximum_cold_damage_resistance_% = 10] + }, +} + +mods["PlayerMonsterLightningResistance1"] = { + name = "Lightning Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "lightning_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has +50% to Lightning Resistance and +10% to Maximum Lightning Resistance.", + }, + modList = { + mod("LightningResist", "BASE", 50, 0, 0), -- PlayerMonsterLightningResistance1 [base_lightning_damage_resistance_% = 50] + mod("LightningResistMax", "BASE", 10, 0, 0), -- PlayerMonsterLightningResistance1 [base_maximum_lightning_damage_resistance_% = 10] + }, +} + +mods["PlayerMonsterArmourPenetration1"] = { + name = "Breaks Armour", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster Breaks Armour equal to 1000% of Physical Damage dealt.", + }, + modList = { + mod("Condition:CanArmourBreak", "FLAG", 1000, 0, 0, { effectName = "ArmourBreak", effectType = "Buff", type = "GlobalEffect" }), -- PlayerMonsterArmourPenetration1 [armour_break_physical_damage_%_dealt_as_armour_break = 1000] + }, +} + +mods["PlayerMonsterIncreasedAccuracy1"] = { + name = "Accurate", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has 200% increased Accuracy Rating.", + }, + modList = { + mod("Accuracy", "INC", 200, 0, 0), -- PlayerMonsterIncreasedAccuracy1 [accuracy_rating_+% = 200] + }, +} + +mods["PlayerMonsterDamageGainedAsChaos1"] = { + name = "Extra Chaos Damage", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "chaos_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster Gains 40% of damage as extra Chaos damage.", + }, + modList = { + mod("DamageGainAsChaos", "BASE", 40, 0, 0), -- PlayerMonsterDamageGainedAsChaos1 [non_skill_base_all_damage_%_to_gain_as_chaos = 40] + }, +} + +mods["PlayerMonsterLifeRegenerationRatePercentage1"] = { + name = "Regenerates Life", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster Regenerates 2% of Maximum Life per second.", + }, + modList = { + mod("LifeRegenPercent", "BASE", 2, 0, 0), -- PlayerMonsterLifeRegenerationRatePercentage1 [life_regeneration_rate_per_minute_% = 120] + }, +} + +mods["PlayerMonsterAdditionalProjectiles1"] = { + name = "Additional Projectiles", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "allows_additional_projectiles", weight = 1 }, + { tag = "default", weight = 0 }, + }, + statDescriptions = { + "Monster fires 4 additional Projectiles.", + }, + modList = { + mod("ProjectileCount", "BASE", 4, 0, 0), -- PlayerMonsterAdditionalProjectiles1 [number_of_additional_projectiles = 4] + }, +} + +mods["PlayerMonsterAreaOfEffect1"] = { + name = "Increased Area of Effect", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "allows_inc_aoe", weight = 1 }, + { tag = "default", weight = 0 }, + }, + statDescriptions = { + "Monster has 100% Increased Area of Effect.", + "100% more Area of Effect", + }, + modList = { + -- PlayerMonsterAreaOfEffect1 [rare_monster_mod_area_of_effect_+%_final = 100] + }, +} + +mods["PlayerMonsterIgniteChanceIncrease1"] = { + name = "All Damage Ignites", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "fire_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + mod("PhysicalCanIgnite", "FLAG", 1, 0, 0), -- PlayerMonsterIgniteChanceIncrease1 [all_damage_can_ignite = 1] + mod("EnemyIgniteChance", "BASE", 100, 0, 0), -- PlayerMonsterIgniteChanceIncrease1 [always_ignite = 1] + }, +} + +mods["PlayerMonsterFreezeDamageIncrease1"] = { + name = "All Damage Chills", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "cold_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + -- PlayerMonsterFreezeDamageIncrease1 [all_damage_can_chill = 1] + -- PlayerMonsterFreezeDamageIncrease1 [chill_minimum_slow_% = 10] + }, +} + +mods["PlayerMonsterShockChanceIncrease1"] = { + name = "All Damage Shocks", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "lightning_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + mod("PhysicalCanShock", "FLAG", 1, 0, 0), -- PlayerMonsterShockChanceIncrease1 [all_damage_can_shock = 1] + mod("EnemyShockChance", "BASE", 100, 0, 0), -- PlayerMonsterShockChanceIncrease1 [always_shock = 1] + }, +} + +mods["PlayerMonsterBurningGroundTrail1"] = { + name = "Trail of Fire", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "sanctum_monster", weight = 0 }, + { tag = "immobile", weight = 0 }, + { tag = "ranged", weight = 0 }, + { tag = "fire_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterChilledGroundTrail1"] = { + name = "Trail of Ice", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "immobile", weight = 0 }, + { tag = "ranged", weight = 0 }, + { tag = "cold_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterShockedGroundTrail1"] = { + name = "Trail of Lightning", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "immobile", weight = 0 }, + { tag = "ranged", weight = 0 }, + { tag = "lightning_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster leaves a trail of Shocked Ground as they move.", + }, + modList = { + }, +} + +mods["PlayerMonsterImmuneToSlow1"] = { + name = "Slow Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has 50% reduced Slowing Potency of Debuffs on them.", + "50% less Slowing Potency of Debuffs on me", + }, + modList = { + -- PlayerMonsterImmuneToSlow1 [monster_slow_potency_+%_final = -50] + }, +} + +mods["PlayerMonsterModReducedCritMulti1"] = { + name = "Crit Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Hits against this Monster have 80% reduced Critical Damage Bonus.", + }, + modList = { + mod("SelfCritMultiplier", "INC", -80, 0, 0), -- PlayerMonsterModReducedCritMulti1 [base_self_critical_strike_multiplier_-% = 80] + }, +} + +mods["PlayerMonsterChaosResistance1"] = { + name = "Chaos Resistant", + type = "Suffix", + tier = 1, + spawnWeights = { + { tag = "chaos_affinity", weight = 1 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster has +50% to Chaos Resistance.", + }, + modList = { + mod("ChaosResist", "BASE", 50, 0, 0), -- PlayerMonsterChaosResistance1 [base_chaos_damage_resistance_% = 50] + }, +} + +mods["PlayerMonsterFlameBeacons1"] = { + name = "Periodic Fire Explosions", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterFrostBeacons1"] = { + name = "Periodic Cold Explosions", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterLightningBeacons1"] = { + name = "Periodic Lightning Explosions", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterStrongerMinions1"] = { + name = "Powerful Minions", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster's Pack Minions have 25% increased Damage and 50% increased Life.", + }, + modList = { + }, +} + +mods["PlayerMonsterPhysicalDamageAura1"] = { + name = "Extra Physical Damage Aura", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that grants 40% increased Physical Damage to Allies within 5 metres.", + }, + modList = { + mod("PhysicalDamage", "INC", 40, 0, 0), -- PlayerMonsterPhysicalDamageAura1 [physical_damage_+% = 40] + }, +} + +mods["PlayerMonsterIncreasedSpeedAura1"] = { + name = "Haste Aura", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "fast_movement", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that grants 20% increased Attack and Cast speed and 10% increased Movement speed to Allies within 5 metres.", + }, + modList = { + mod("Speed", "INC", 25, 0, 0), -- PlayerMonsterIncreasedSpeedAura1 [attack_and_cast_speed_+% = 25] + mod("MovementSpeed", "INC", 25, 0, 0), -- PlayerMonsterIncreasedSpeedAura1 [base_movement_velocity_+% = 25] + }, +} + +mods["PlayerMonsterEnergyShieldAura1"] = { + name = "Energy Shield Aura", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that grants 20% of Maximum life as added Energy Shield to Allies within 5 metres.", + }, + modList = { + -- PlayerMonsterEnergyShieldAura1 [base_maximum_life_%_to_gain_as_total_energy_shield = 30] + }, +} + +mods["PlayerMonsterResistanceAura1"] = { + name = "Elemental Resistance Aura", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that grants +35% to all Elemental Resistances to Allies within 5 metres.", + }, + modList = { + mod("ElementalResist", "BASE", 35, 0, 0), -- PlayerMonsterResistanceAura1 [base_resist_all_elements_% = 35] + }, +} + +mods["PlayerMonsterTemporalAura1"] = { + name = "Temporal Bubble", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that Debuffs Enemies within 3.2 metres; Slowing by 25%, making effects expire 40% slower and reducing Cooldown Recovery Rate by 60%.", + }, + modList = { + -- PlayerMonsterTemporalAura1 [action_speed_-% = 25] + -- PlayerMonsterTemporalAura1 [debuff_time_passed_+% = -40] + mod("CooldownRecovery", "INC", -60, 0, 0), -- PlayerMonsterTemporalAura1 [base_cooldown_speed_+% = -60] + -- PlayerMonsterTemporalAura1 [cannot_be_damaged_by_things_outside_radius = 0] + }, +} + +mods["PlayerMonsterHinderAura1"] = { + name = "Hinder Aura", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that Hinders enemies within 3.6 metres.", + }, + modList = { + }, +} + +mods["PlayerMonsterPreventRecoveryAura1"] = { + name = "Prevents Recovery Above 50%", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that Debuffs enemies within 4.2 metres, causing their Life and Energy Shield to not be able to recover past 50%.", + }, + modList = { + }, +} + +mods["PlayerMonsterImmuneAura1"] = { + name = "Periodic Invulnerability Aura", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster releases a nova that makes Allies invulnerable for 5 seconds while the monster is alive within 5 metres every 12 seconds.", + }, + modList = { + -- PlayerMonsterImmuneAura1 [monster_allies_cannot_take_damage_pulse_owner = 1] + }, +} + +mods["PlayerMonsterImmuneAura2"] = { + name = "Empowered Periodic Invulnerability Aura", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster releases a nova that makes Allies invulnerable for 5 seconds while the monster is alive within 5 metres every 12 seconds.", + }, + modList = { + -- PlayerMonsterImmuneAura2 [monster_allies_cannot_take_damage_pulse_owner = 1] + }, +} + +mods["PlayerMonsterManaSiphonAura1"] = { + name = "Siphons Mana and Deals Lightning Damage", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "ranged", weight = 0 }, + { tag = "sanctum_monster", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates a circular effect that drains Mana and deals Lightning Damage over time to enemies near the edge of the circle.", + }, + modList = { + }, +} + +mods["PlayerMonsterManaSiphonAura2"] = { + name = "Siphons Mana and Deals Lightning Damage", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "ranged", weight = 0 }, + { tag = "sanctum_monster", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates a circular effect that drains Mana and deals Lightning Damage over time to enemies near the edge of the circle. Additionally, Monster will periodically create separate circles that drain Mana and deal Lightning Damage over time to enemies standing in them.", + }, + modList = { + }, +} + +mods["PlayerMonsterHealingNova1"] = { + name = "Heals Allies and Suppresses Foe Recovery", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster releases a nova that reduces Enemy Life and Energy Shield Recovery Rate by 60% and causes Allies to Regenerate 5.5% of Maximum Life per second for 4 seconds within 5 metres every 8 seconds.", + }, + modList = { + }, +} + +mods["PlayerMonsterFlaskRemovalAura1"] = { + name = "Siphons Flask Charges", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates an Aura that removes 3 Flask and Charm charges from enemies every 3 seconds within 3.6 metres.", + }, + modList = { + -- PlayerMonsterFlaskRemovalAura1 [generate_x_charges_for_any_flask_per_minute = -3] + }, +} + +mods["PlayerMonsterRevivesMinions1"] = { + name = "Reviving Minions", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "quest_null_monster_mods", weight = 0 }, + { tag = "boss", weight = 0 }, + { tag = "no_minion_revival", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically revives Pack Minions.", + }, + modList = { + }, +} + +mods["PlayerMonsterRevivesMinions2"] = { + name = "Empowered Reviving Minions", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "quest_null_monster_mods", weight = 0 }, + { tag = "boss", weight = 0 }, + { tag = "no_minion_revival", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically revives Pack Minions with increased Life and Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterMinionsTakeLifeInstead1"] = { + name = "Damage Taken From Minions First", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "boss", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "50% of damage taken from Monster is taken from Monster's Pack Minions instead", + }, + modList = { + -- PlayerMonsterMinionsTakeLifeInstead1 [damage_removed_from_pack_minions_before_life_or_es_% = 50] + }, +} + +mods["PlayerMonsterShroudWalker1"] = { + name = "Shroud Walker", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "sanctum_monster", weight = 0 }, + { tag = "immobile", weight = 0 }, + { tag = "no_shroud_walker", weight = 0 }, + { tag = "boss", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically teleports to an enemy they can see, creating a Smoke Cloud where they leave and where they teleport to.", + }, + modList = { + }, +} + +mods["PlayerMonsterShroudWalker2"] = { + name = "Shroud Walker", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "sanctum_monster", weight = 0 }, + { tag = "immobile", weight = 0 }, + { tag = "no_shroud_walker", weight = 0 }, + { tag = "boss", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically teleports to an enemy they can see, creating a Smoke Cloud where they leave and where they teleport to.", + }, + modList = { + }, +} + +mods["PlayerMonsterPeriodicEnrage1"] = { + name = "Periodically Enrages", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically Enrages; gaining 30% increased Damage, 25% increased Skill and Movement Speed and 33% less damage taken for 5 seconds every 10 seconds.", + }, + modList = { + }, +} + +mods["PlayerMonsterPeriodicEnrage2"] = { + name = "Enraged", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster is Enraged; gaining 30% increased Damage, 25% increased Skill and Movement Speed and 33% less damage taken.", + }, + modList = { + }, +} + +mods["PlayerMonsterCorpseExploder1"] = { + name = "Explodes Nearby Corpses", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterLightningMirage1"] = { + name = "Lightning Mirage When Hit", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates a Mirage when Hit that moves towards enemies and explodes when it gets close enough, dealing Lightning Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterLightningMirage2"] = { + name = "Lightning Mirages When Hit", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates Mirages when Hit that move towards enemies and explode when they get close enough, dealing Lightning Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterMagmaBarrier1"] = { + name = "Magma Barrier", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "sanctum_monster", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates a 90% damage absorption barrier that explodes after taking a certain amount of damage, dealing Fire Damage", + }, + modList = { + }, +} + +mods["PlayerMonsterFlamewaller1"] = { + name = "Conjures Flamewalls", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "sanctum_monster", weight = 0 }, + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster creates circular walls of Fire that deal damage to enemies standing in them.", + }, + modList = { + }, +} + +mods["PlayerMonsterLightningStorms1"] = { + name = "Conjures Lightning Storms", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + }, + modList = { + }, +} + +mods["PlayerMonsterVolatilePlants1"] = { + name = "Volatile Plants", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically creates Volatile Plants, releasing orbs that move towards enemies; exploding when they get close enough; dealing Chaos Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterVolatilePlants2"] = { + name = "Empowering Volatile Plants", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically creates Powerful Volatile Plants, releasing orbs that move towards enemies; exploding when they get close enough; dealing Chaos Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterVolatileRocks1"] = { + name = "Volatile Crag", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically creates Volatile Crag that moves towards enemies; exploding when they get close enough; dealing Fire Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterVolatileRocks2"] = { + name = "Empowering Volatile Crag", + type = "Prefix", + tier = 2, + spawnWeights = { + { tag = "magic", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster periodically creates Powerful Volatile Crag that moves towards enemies; exploding when they get close enough; dealing Fire Damage.", + }, + modList = { + }, +} + +mods["PlayerMonsterProximalTangibility1"] = { + name = "Proximal Tangibility", + type = "Prefix", + tier = 1, + spawnWeights = { + { tag = "no_proximity_shield", weight = 0 }, + { tag = "boss", weight = 0 }, + { tag = "default", weight = 1 }, + }, + statDescriptions = { + "Monster cannot be damaged by enemies any further than 3 metres from them.", + }, + modList = { + -- PlayerMonsterProximalTangibility1 [ignore_cannot_be_damaged_by_enemies = 0] + }, +} diff --git a/src/Export/Scripts/tamedBeastMods.lua b/src/Export/Scripts/tamedBeastMods.lua new file mode 100644 index 000000000..debf7770f --- /dev/null +++ b/src/Export/Scripts/tamedBeastMods.lua @@ -0,0 +1,192 @@ +local function makeSkillMod(modName, modType, modVal, flags, keywordFlags, ...) + return { + name = modName, + type = modType, + value = modVal, + flags = flags or 0, + keywordFlags = keywordFlags or 0, + ... + } +end +local function makeFlagMod(modName, ...) + return makeSkillMod(modName, "FLAG", true, 0, 0, ...) +end +local function makeSkillDataMod(dataKey, dataValue, ...) + return makeSkillMod("SkillData", "LIST", { key = dataKey, value = dataValue }, 0, 0, ...) +end +dofile("../Data/Global.lua") +local skillStatMap = LoadModule("../Data/SkillStatMap.lua", makeSkillMod, makeFlagMod, makeSkillDataMod) + +if not loadStatFile then + dofile("statdesc.lua") +end +-- Tamed beasts are itemised captured monsters; their mods render with this description file +loadStatFile("monster_stat_descriptions.csd") + +local function tableToString(tbl, pre) + pre = pre or "" + local tableString = "{ " + local outNames = { } + for name in pairs(tbl) do + table.insert(outNames, name) + end + table.sort(outNames) + for _, name in ipairs(outNames) do + if type(tbl[name]) == "table" then + tableString = tableString .. tableToString(tbl[name], pre .. name .. ".") + else + if _ > 1 then + tableString = tableString .. ", " + end + tableString = tableString .. pre .. name .. " = " .. (type(tbl[name]) == "string" and '"' or '') .. tostring(tbl[name]) .. (type(tbl[name]) == "string" and '"' or '') + end + end + return tableString .. " }" +end + +local function escapeString(text) + return text:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub("%s*[\r\n]+%s*", " ") +end + +-- GGG keyword markup is stripped with the shared escapeGGGString (Modules/Common.lua, +-- loaded by the export tool) — the same function the importer applies to incoming lines, +-- so stored display lines always match imported ones verbatim. + +local MONSTER_DOMAIN = 3 -- see modDomains in enums.lua +local GEN_PREFIX = 1 +local GEN_SUFFIX = 2 + +-- Player-facing display names for monster mods; this is the same keyword popup +-- system the character API uses for its "[ModId|Display]" markup. The popup +-- Description is the full explanation shown in-game (good tooltip text). +local popupNames, popupDescriptions = { }, { } +for popup in dat("KeywordPopups"):Rows() do + if popup.Name and popup.Name ~= "" then + popupNames[popup.Id] = popup.Name + end + if popup.Description and popup.Description ~= "" then + popupDescriptions[popup.Id] = popup.Description + end +end +-- Nameplate lines for rare monster mods without keyword popups (e.g. "Periodically +-- unleashes [Cold|Ice]"); the table name is a PoE1 leftover GGG reuses in PoE2 +local archNames = { } +for archMod in dat("ArchnemesisMods"):Rows() do + if archMod.Id and archMod.Name and archMod.Name ~= "" then + archNames[archMod.Id.Id] = archMod.Name + end +end + +local out = io.open("../Data/TamedBeastMods.lua", "w") +out:write('-- This file is automatically generated, do not edit!\n') +out:write('-- Path of Building\n') +out:write('--\n') +out:write('-- Tamed Beast (Companion) Modifier Data\n') +out:write('-- Monster data (c) Grinding Gear Games\n') +out:write('\n') +out:write('local mods, mod, flag = ...\n') + +-- Tamed beasts roll their mods in the wild as "Monster*" mods (the ones with real +-- spawn weights); the itemised companion carries the standalone "PlayerMonster*" +-- twin, which is what the character API reports on import, so Player ids are the +-- only ids exported. Display text (keyword popups, nameplate lines) is keyed by +-- the Monster ids, so name and descriptions resolve through the twin; stats come +-- from the Player row itself, which is authoritative for the companion. +local exported, unmappedStats, seen = 0, { }, { } +for archMod in dat("ArchnemesisMods"):Rows() do + local playerRow = archMod.Id + local id = playerRow and playerRow.Id + if id and id:match("^Player") and not seen[id] then + seen[id] = true + local twinId = id:gsub("^Player", "", 1) + local twinRow = dat("Mods"):GetRow("Id", twinId) + -- The mod must exist on the beast side as a rollable prefix/suffix: that is + -- what can appear on a captured beast. + local rollable = false + if twinRow and twinRow.Domain == MONSTER_DOMAIN and (twinRow.GenerationType == GEN_PREFIX or twinRow.GenerationType == GEN_SUFFIX) then + for _, weight in ipairs(twinRow.SpawnWeight) do + if weight > 0 then + rollable = true + break + end + end + end + if rollable then + local stats = { } + for i = 1, 6 do + if playerRow["Stat"..i] then + stats[playerRow["Stat"..i].Id] = { min = playerRow["Stat"..i.."Value"][1], max = playerRow["Stat"..i.."Value"][1] } + end + end + if playerRow.Type then + stats.Type = playerRow.Type + end + local descLines = describeStats(stats) + local popupName = popupNames[twinId] + local archName = archNames[twinId] and escapeGGGString(archNames[twinId]) + local popupDescription = popupDescriptions[twinId] and escapeGGGString(popupDescriptions[twinId]) + local name = popupName or archName or (twinRow.Name ~= "" and twinRow.Name) or descLines[1] or twinId + out:write('\nmods["', id, '"] = {\n') + out:write('\tname = "', escapeString(name), '",\n') + out:write('\ttype = "', twinRow.GenerationType == GEN_PREFIX and "Prefix" or "Suffix", '",\n') + local tier = tonumber(id:match("(%d+)$")) + if tier then + out:write('\ttier = ', tier, ',\n') + end + if #twinRow.SpawnTags ~= #twinRow.SpawnWeight then + printf("Warning: %s has %d spawn tags but %d weights", twinId, #twinRow.SpawnTags, #twinRow.SpawnWeight) + end + out:write('\tspawnWeights = {\n') + for i, tagRow in ipairs(twinRow.SpawnTags) do + out:write('\t\t{ tag = "', tagRow.Id, '", weight = ', twinRow.SpawnWeight[i] or 0, ' },\n') + end + out:write('\t},\n') + out:write('\tstatDescriptions = {\n') + if archName and archName ~= name then + out:write('\t\t"', escapeString(archName), '",\n') + end + if popupDescription then + out:write('\t\t"', escapeString(popupDescription), '",\n') + end + for _, line in ipairs(descLines) do + out:write('\t\t"', escapeString(line), '",\n') + end + out:write('\t},\n') + out:write('\tmodList = {\n') + for i = 1, 6 do + if playerRow["Stat"..i] then + local statId = playerRow["Stat"..i].Id + local modStats = ' [' .. statId .. ' = ' .. playerRow["Stat"..i.."Value"][1] .. ']' + if skillStatMap[statId] then + local newMod = skillStatMap[statId][1] + out:write('\t\tmod("', newMod.name, '", "', newMod.type, '", ', newMod.value and type(newMod.value) ~= "boolean" and tableToString(newMod.value) or (skillStatMap[statId].value or playerRow["Stat"..i.."Value"][1] * (skillStatMap[statId].mult or 1) / (skillStatMap[statId].div or 1)), ', ', newMod.flags or 0, ', ', newMod.keywordFlags or 0) + for _, extra in ipairs(newMod) do + out:write(', ', tableToString(extra)) + end + out:write('), -- ', id, modStats, '\n') + else + out:write('\t\t-- ', id, modStats, '\n') + unmappedStats[statId] = (unmappedStats[statId] or 0) + 1 + end + end + end + out:write('\t},\n') + out:write('}\n') + exported = exported + 1 + end + end +end +out:close() + +print("Tamed beast mod data exported: " .. exported .. " mods.") +local unmappedList = { } +for statId, count in pairs(unmappedStats) do + table.insert(unmappedList, { statId = statId, count = count }) +end +table.sort(unmappedList, function(a, b) return a.count > b.count end) +if unmappedList[1] then + print(#unmappedList .. " stats had no SkillStatMap entry (emitted as comments):") + for _, entry in ipairs(unmappedList) do + print(" " .. entry.statId .. " (x" .. entry.count .. ")") + end +end diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index c5dd0b6c0..cfa862526 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -431,6 +431,11 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin srcInstance.nameSpec = "Companion: ".. value.label end end + -- the Skills tab gem slot text is only written by SetDisplayGroup; refresh it + -- if the renamed gem's group is the one on display there + if self.skillsTab.displayGroup == mainSocketGroup then + self.skillsTab:SetDisplayGroup(mainSocketGroup) + end self.modFlag = true self.buildFlag = true end) diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index 079f36049..c045dcc4c 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -1105,7 +1105,12 @@ function calcs.createMinionSkills(env, activeSkill) t_insert(minion.activeSkillList, minionSkill) end local skillIndex - if env.mode == "CALCS" then + if activeSkill.minionSkillIndexOverride then + -- Transient override used by calcFullDPS to evaluate each selected companion + -- attack in turn; never written back to srcInstance so saved builds and the + -- sidebar's active attack selection are unaffected + skillIndex = m_max(m_min(activeSkill.minionSkillIndexOverride, #minion.activeSkillList), 1) + elseif env.mode == "CALCS" then skillIndex = m_max(m_min(activeEffect.srcInstance.skillMinionSkillCalcs or 1, #minion.activeSkillList), 1) activeEffect.srcInstance.skillMinionSkillCalcs = skillIndex else diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 8dcf95a33..defdcb053 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -1019,6 +1019,21 @@ function calcs.perform(env, skipEHP) for _, mod in ipairs(env.player.mainSkill.extraSkillModList) do env.minion.modDB:AddMod(mod) end + -- Rolled monster mods on rare tamed beast companions (imported from tamedBeastProperties). + local mainActiveEffect = env.player.mainSkill.activeEffect + local mainGrantedEffect = mainActiveEffect and mainActiveEffect.grantedEffect + local mainSrcInstance = mainActiveEffect and mainActiveEffect.srcInstance + if mainSrcInstance and mainSrcInstance.tamedBeastModList and mainGrantedEffect and mainGrantedEffect.minionList and mainGrantedEffect.name:match("^Companion") then + for _, beastMod in ipairs(mainSrcInstance.tamedBeastModList) do + local beastModData = beastMod.enabled and beastMod.modId and env.data.tamedBeastMods[beastMod.modId] + -- A mod the beast's tags can never roll is ignoreed + if beastModData and data.beastModCanSpawn(beastModData, env.minion.minionData.monsterTags) then + for _, mod in ipairs(beastModData.modList) do + env.minion.modDB:AddMod(mod) + end + end + end + end if env.talismanModList then -- Adding mods provided by "Necromantic Talisman" env.minion.modDB:AddList(env.talismanModList) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 7f06f8bd6..2572af98a 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -176,6 +176,56 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local burningGroundSource = "" local causticGroundSource = "" + -- Re-Build env calculator for new run + local function reinitEnv() + local accelerationTbl = { + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skills = true, + everything = true, + } + fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl }) + end + + -- Collect the minion's output of one calc pass into the Full DPS totals; + -- returns the minion name prefix when the minion contributed hit DPS + local function harvestMinionOutput(usedEnv, activeSkill, skillName, activeSkillCount) + local minionName = nil + if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then + minionName = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData.name..": ") or "" + -- Minion attacks list as their own entries: the attack name leads and the + -- owning skill is shown as the source ("from Companion: ") + t_insert(fullDPS.skills, { name = activeSkill.skillPartName, dps = usedEnv.minion.output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, source = skillName }) + fullDPS.combinedDPS = fullDPS.combinedDPS + usedEnv.minion.output.TotalDPS * activeSkillCount + end + if usedEnv.minion.output.BleedDPS and usedEnv.minion.output.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = usedEnv.minion.output.BleedDPS + bleedSource = skillName + end + if usedEnv.minion.output.IgniteDPS and usedEnv.minion.output.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = usedEnv.minion.output.IgniteDPS + igniteSource = skillName + end + if usedEnv.minion.output.PoisonDPS and usedEnv.minion.output.PoisonDPS > fullDPS.poisonDPS then + fullDPS.poisonDPS = usedEnv.minion.output.PoisonDPS + poisonSource = skillName + end + if usedEnv.minion.output.ImpaleDPS and usedEnv.minion.output.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.minion.output.ImpaleDPS * activeSkillCount + end + if usedEnv.minion.output.DecayDPS and usedEnv.minion.output.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.minion.output.DecayDPS + end + if usedEnv.minion.output.TotalDot and usedEnv.minion.output.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.minion.output.TotalDot + end + if usedEnv.minion.output.CullMultiplier and usedEnv.minion.output.CullMultiplier > 1 and usedEnv.minion.output.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = usedEnv.minion.output.CullMultiplier + end + return minionName + end + for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = calcs.getActiveSkillCount(activeSkill) @@ -183,36 +233,44 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv - local minionName = nil - if activeSkill.minion or usedEnv.minion then - if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then - minionName = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData.name..": ") or "" - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = usedEnv.minion.output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName..activeSkill.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + usedEnv.minion.output.TotalDPS * activeSkillCount - end - if usedEnv.minion.output.BleedDPS and usedEnv.minion.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = usedEnv.minion.output.BleedDPS - bleedSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.minion.output.IgniteDPS and usedEnv.minion.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = usedEnv.minion.output.IgniteDPS - igniteSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.minion.output.PoisonDPS and usedEnv.minion.output.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = usedEnv.minion.output.PoisonDPS - poisonSource = activeSkill.activeEffect.grantedEffect.name + -- Companion/Spectre gems all share a single granted effect ("Companion: {0}"/"Spectre: {0}"), + -- whose name is mutated globally for display, so derive the entry name from this skill's own minion + local skillName = activeSkill.activeEffect.grantedEffect.name + local skillMinion = activeSkill.minion or usedEnv.minion + if skillMinion and skillMinion.minionData then + if skillName:match("^Companion:") then + skillName = "Companion: "..skillMinion.minionData.name + elseif skillName:match("^Spectre:") then + skillName = "Spectre: "..skillMinion.minionData.name end - if usedEnv.minion.output.ImpaleDPS and usedEnv.minion.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.minion.output.ImpaleDPS * activeSkillCount - end - if usedEnv.minion.output.DecayDPS and usedEnv.minion.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.minion.output.DecayDPS + end + -- Resolve the companion attack multi-select: each selected attack is + -- evaluated as its own Full DPS entry; the active attack only counts + -- if selected. Ids that don't match this beast's attacks are stale + -- (e.g. the beast was swapped) and leave the default behaviour. + local extraPasses = { } + local harvestPass1 = true + local selection = activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.fullDPSMinionSkills + local minionSkills = activeSkill.minion and activeSkill.minion.activeSkillList + if selection and minionSkills and skillName:match("^Companion") then + local activeIndex = isValueInArray(minionSkills, activeSkill.minion.mainSkill) + local anyMatch = false + for i, minionSkill in ipairs(minionSkills) do + if selection[minionSkill.activeEffect.grantedEffect.id] then + anyMatch = true + if i ~= activeIndex then + t_insert(extraPasses, i) + end + end end - if usedEnv.minion.output.TotalDot and usedEnv.minion.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.minion.output.TotalDot + if anyMatch then + harvestPass1 = activeIndex and selection[minionSkills[activeIndex].activeEffect.grantedEffect.id] or false end - if usedEnv.minion.output.CullMultiplier and usedEnv.minion.output.CullMultiplier > 1 and usedEnv.minion.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = usedEnv.minion.output.CullMultiplier + end + local minionName = nil + if activeSkill.minion or usedEnv.minion then + if harvestPass1 then + minionName = harvestMinionOutput(usedEnv, activeSkill, skillName, activeSkillCount) end -- This is a fix to prevent Absolution spell hit from being counted multiple times when increasing minions count if activeSkill.activeEffect.grantedEffect.name == "Absolution" and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce") then @@ -304,15 +362,20 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullDPS.cullingMulti = usedEnv.player.output.CullMultiplier end - -- Re-Build env calculator for new run - local accelerationTbl = { - nodeAlloc = true, - requirementsItems = true, - requirementsGems = true, - skills = true, - everything = true, - } - fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl }) + reinitEnv() + + -- Evaluate the remaining selected companion attacks, one Full DPS entry each + for _, attackIndex in ipairs(extraPasses) do + activeSkill.minionSkillIndexOverride = attackIndex + fullEnv.player.mainSkill = activeSkill + calcs.perform(fullEnv, true) + usedEnv = fullEnv + if usedEnv.minion then + harvestMinionOutput(usedEnv, activeSkill, skillName, activeSkillCount) + end + reinitEnv() + end + activeSkill.minionSkillIndexOverride = nil end end end diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 8fc98820d..0daf439a2 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -996,6 +996,53 @@ for _, minion in pairs(data.minions) do mod.source = "Minion:"..minion.name end end +-- Load tamed beast (companion) monster mods +data.tamedBeastMods = { } +LoadModule("Data/TamedBeastMods", data.tamedBeastMods, makeSkillMod, makeFlagMod) +-- Normalises a beast mod display line for matching; nameplate lines are fixed labels +-- without roll values, so case folding and trimming suffice +function data.normaliseBeastModLine(line) + return (line:lower():gsub("^%s+", ""):gsub("%s+$", "")) +end + +function data.beastModCanSpawn(beastMod, monsterTags) + local tagSet = { } + for _, tag in ipairs(monsterTags or { }) do + tagSet[tag] = true + end + for _, entry in ipairs(beastMod.spawnWeights) do + if entry.tag == "default" or tagSet[entry.tag] then + return entry.weight > 0 + end + end + return false +end +-- Secondary index for import lines that don't carry a "[ModId|...]" token; iterate ids in +-- sorted order so collisions deterministically resolve to the lowest id +data.tamedBeastModsByDisplay = { } +do + local sortedIds = { } + for id in pairs(data.tamedBeastMods) do + table.insert(sortedIds, id) + end + table.sort(sortedIds) + for _, id in ipairs(sortedIds) do + local beastMod = data.tamedBeastMods[id] + for _, mod in ipairs(beastMod.modList) do + mod.source = "Beast Mod:"..beastMod.name + end + for _, line in ipairs(beastMod.statDescriptions) do + local key = data.normaliseBeastModLine(line) + if not data.tamedBeastModsByDisplay[key] then + data.tamedBeastModsByDisplay[key] = id + end + end + local nameKey = data.normaliseBeastModLine(beastMod.name) + if not data.tamedBeastModsByDisplay[nameKey] then + data.tamedBeastModsByDisplay[nameKey] = id + end + end +end data.printMissingMinionSkills = function() local missing = { } for _, minion in pairs(data.minions) do