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.
This commit is contained in:
Kamron Batman 2026-09-23 10:29:20 -07:00 • committed by GitHub
parent 6b910ff089
commit 3eb3f0fb5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 149 additions and 13 deletions

View file

@ -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<Mobile> _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();
}
}
}

View file

@ -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) =>

View file

@ -920,6 +920,10 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public bool IsPrisoner { get; set; }
/// <summary>
/// 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.
/// </summary>
public virtual bool FollowsAcquireRules => true;
public virtual Faction FactionAllegiance => null;

View file

@ -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);

View file

@ -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);