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/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/SkillsTab.lua b/src/Classes/SkillsTab.lua index 3c43bfffe..288df43bc 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -282,6 +282,27 @@ 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) modifiers + self.anchorBeastMods = new("Control", nil, {0, 0, 0, 0}) + self.anchorBeastMods: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) + 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 +407,13 @@ 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", + }) end end @@ -537,6 +565,15 @@ 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 t_insert(node, gemInfo) end t_insert(child, node) @@ -603,6 +640,7 @@ function SkillsTabClass:Draw(viewPort, inputEvents) end self:UpdateGemSlots() + self:UpdateBeastModSlots() self:DrawControls(viewPort) end @@ -787,6 +825,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 +1140,214 @@ function SkillsTabClass:CreateGemSlot(index) self.controls["gemSlot"..index.."EnableGlobal2"] = slot.enableGlobal2 end +-- Returns the displayed group's Companion gem, if any (only companions carry tamed beast mods) +function SkillsTabClass:GetDisplayedBeastGem() + if not self.displayGroup then + return + end + for _, gemInstance in ipairs(self.displayGroup.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: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/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/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