From 3eb3f0fb5c080b1882eba5ceb330e307c7ff5730 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:29:20 -0700 Subject: [PATCH] fix(ai): restore pre-AOS berserk energy vortexes and blade spirits (#2666) #2132 let a player's energy vortex or blade spirits in pre-AOS Felucca target their own caster, gated by `FollowsAcquireRules`. #2232's AI overhaul moved the summon checks into `IsInvalidSummonTarget`, which excluded the caster in every era, and dropped the gate. Since then `FollowsAcquireRules` has had no readers and summons have never turned on their caster. ## Changes - `IsInvalidSummonTarget` reads `FollowsAcquireRules` again. A summon that follows the acquire rules spares its caster and anyone the caster's harmful spells would spare. The vortex and blade spirits don't follow them in pre-AOS Felucca when a player summoned them. - The animated-dead restrictions (no players, no other animated dead, no pets) apply to every animated summon regardless of that setting. - `AcquireNewFocusMob` sets a berserk summon's caster aside and targets them only when nobody else is available. #2132 meant to prefer others, but its check only worked when the caster was scanned before the other candidates, and the vortex's Int-plus-Magery ranking usually puts a nearby caster first. - The AOS summon-target check no longer dereferences a missing `SummonMaster`. A summon whose caster was deleted before a reload comes back without one. Controlled summons are unaffected: since #2665 they target through `HandleControlled` and never reach this scan. ## Tests `SummonAcquireRulesTests` has 6 cases, skipped without client map data. Pre-AOS Felucca: a vortex and blade spirits each target their caster when the caster is alone, and a vortex targets a bystander even when the caster ranks higher. Pre-AOS Trammel and AOS Felucca: the caster is still spared. An AOS scan next to a summon with no master doesn't throw. `UOContent.Tests`: 1148 passed, 2 skipped, 0 failed on Windows. --- .../Mobiles/AI/SummonAcquireRulesTests.cs | 113 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 39 ++++-- Projects/UOContent/Mobiles/BaseCreature.cs | 4 + .../Monsters/Misc/Melee/BladeSpirits.cs | 3 +- .../Monsters/Misc/Melee/EnergyVortex.cs | 3 +- 5 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs new file mode 100644 index 000000000..7bd3b7c70 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class SummonAcquireRulesTests : IDisposable +{ + private readonly List _created = []; + private readonly Expansion _previous = Core.Expansion; + + private PlayerMobile Player(Map map, int x, int intel) + { + var player = new PlayerMobile { Player = true, RawInt = intel }; + player.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map); + _created.Add(player); + return player; + } + + private BaseCreature Summon(BaseCreature summon, Mobile caster, int x) + { + _created.Add(summon); + summon.Summoned = true; + summon.SummonMaster = caster; + summon.MoveToWorld(new Point3D(x, 1600, caster.Map.GetAverageZ(x, 1600)), caster.Map); + summon.AIObject.AITimer.Stop(); + return summon; + } + + private static bool Acquire(BaseCreature creature) + { + creature.NextReacquireTime = Core.TickCount - 1; + return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); + } + + [SkippableTheory] + [InlineData(true)] + [InlineData(false)] + public void PreAosFeluccaVortexTurnsOnItsCaster(bool bladeSpirits) + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = Expansion.UOR; + + var caster = Player(Map.Felucca, 1500, 100); + var summon = Summon(bladeSpirits ? new BladeSpirits() : new EnergyVortex(), caster, 1499); + + Assert.True(Acquire(summon)); + Assert.Same(caster, summon.FocusMob); + } + + [SkippableFact] + public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = Expansion.UOR; + + // The caster outranks the bystander on the vortex's int-over-distance ranking. + var caster = Player(Map.Felucca, 1500, 100); + var bystander = Player(Map.Felucca, 1496, 10); + var vortex = Summon(new EnergyVortex(), caster, 1499); + + Assert.True(Acquire(vortex)); + Assert.Same(bystander, vortex.FocusMob); + } + + [SkippableTheory] + [InlineData(Expansion.UOR, 1)] // Trammel + [InlineData(Expansion.AOS, 0)] // Felucca + public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex) + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = expansion; + + var caster = Player(Map.Maps[mapIndex], 1500, 100); + var vortex = Summon(new EnergyVortex(), caster, 1499); + + Assert.False(Acquire(vortex)); + Assert.Null(vortex.FocusMob); + } + + [SkippableFact] + public void MasterlessSummonDoesNotBreakAcquisition() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = Expansion.AOS; + + var player = Player(Map.Felucca, 1500, 100); + var hunter = new PetTestStub(); + _created.Add(hunter); + hunter.MoveToWorld(new Point3D(1499, 1600, player.Z), player.Map); + hunter.AIObject.AITimer.Stop(); + + var masterless = new PetTestStub { Summoned = true }; + _created.Add(masterless); + masterless.MoveToWorld(new Point3D(1498, 1600, player.Z), player.Map); + masterless.AIObject.AITimer.Stop(); + + Assert.True(Acquire(hunter)); + } + + public void Dispose() + { + Core.Expansion = _previous; + + foreach (var mobile in _created) + { + mobile.Delete(); + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index f894a015d..64eecb50e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -996,6 +996,7 @@ public abstract partial class BaseAI Mobile enemySummonMob = null; var val = double.MinValue; var enemySummonVal = double.MinValue; + Mobile summonMaster = null; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { @@ -1005,14 +1006,24 @@ public abstract partial class BaseAI } var bc = m as BaseCreature; - var pm = m as PlayerMobile; - if (IsInvalidSummonTarget(m, bc, pm) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe) - || IsInvalidFightModeTarget(m, acqType, bc)) + if (IsInvalidSummonTarget(m, bc) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe) + || IsInvalidFightModeTarget(m, acqType, bc)) { continue; } + // Only a summon that ignores the acquire rules gets here with its caster; anyone else comes first. + if (m == Mobile.SummonMaster) + { + if (Mobile.InLOS(m)) + { + summonMaster = m; + } + + continue; + } + var theirVal = Mobile.GetFightModeRanking(m, acqType, bPlayerOnly); if (theirVal > val && Mobile.InLOS(m)) @@ -1020,15 +1031,14 @@ public abstract partial class BaseAI newFocusMob = m; val = theirVal; } - else if (Core.AOS && theirVal > enemySummonVal - && Mobile.InLOS(m) && bc?.Summoned == true && bc.Controlled != true) + else if (Core.AOS && theirVal > enemySummonVal && Mobile.InLOS(m) && bc is { Summoned: true, Controlled: false }) { enemySummonMob = m; enemySummonVal = theirVal; } } - Mobile.FocusMob = newFocusMob ?? enemySummonMob; + Mobile.FocusMob = newFocusMob ?? enemySummonMob ?? summonMaster; return Mobile.FocusMob != null; } @@ -1036,21 +1046,28 @@ public abstract partial class BaseAI m.Deleted || m.Blessed || m == Mobile || m is BaseFamiliar || !m.Alive || m.IsDeadBondedPet || m.AccessLevel > AccessLevel.Player || bPlayerOnly && !m.Player || !Mobile.CanSee(m); - private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc, PlayerMobile pm) + private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc) { + // A summon whose caster was deleted before a reload comes back with no master. if (Core.AOS && bc?.Summoned == true && - (bc.SummonMaster == Mobile || !bc.SummonMaster.Player && IsHostile(bc.SummonMaster))) + (bc.SummonMaster == Mobile || bc.SummonMaster is { Player: false } summonMaster && IsHostile(summonMaster))) { return true; } - if (!Mobile.Summoned || Mobile.SummonMaster == null) + var master = Mobile.SummonMaster; + + if (!Mobile.Summoned || master == null) { return false; } - return m == Mobile.SummonMaster || !SpellHelper.ValidIndirectTarget(Mobile.SummonMaster, m) || - Mobile.IsAnimatedDead && (pm != null || bc?.IsAnimatedDead == true || bc?.Controlled == true); + if (Mobile.IsAnimatedDead && (m.Player || bc?.IsAnimatedDead == true || bc?.Controlled == true)) + { + return true; + } + + return Mobile.FollowsAcquireRules && (m == master || !SpellHelper.ValidIndirectTarget(master, m)); } private bool IsInvalidFactionTarget(Mobile m, bool bFacFriend, bool bFacFoe) => diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index cf55821ca..119446f27 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -920,6 +920,10 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public bool IsPrisoner { get; set; } + /// + /// When false, a summon may target its own caster and anyone the caster's harmful spells would spare. + /// It still prefers any other target over the caster. + /// public virtual bool FollowsAcquireRules => true; public virtual Faction FactionAllegiance => null; diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs index a0966eaf4..455418563 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs @@ -55,7 +55,8 @@ namespace Server.Mobiles public override bool BleedImmune => true; public override Poison PoisonImmune => Poison.Lethal; - public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca; + public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || + (Map?.Rules & MapRules.HarmfulRestrictions) != 0; public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) => (m.Str + m.Skills.Tactics.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0); diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs index 3bb2e69fa..efa78c6ba 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs @@ -63,7 +63,8 @@ namespace Server.Mobiles public override bool BleedImmune => true; public override Poison PoisonImmune => Poison.Lethal; - public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca; + public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || + (Map?.Rules & MapRules.HarmfulRestrictions) != 0; public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) => (m.Int + m.Skills.Magery.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);