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:
parent
6b910ff089
commit
3eb3f0fb5c
5 changed files with 149 additions and 13 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -996,6 +996,7 @@ public abstract partial class BaseAI
|
||||||
Mobile enemySummonMob = null;
|
Mobile enemySummonMob = null;
|
||||||
var val = double.MinValue;
|
var val = double.MinValue;
|
||||||
var enemySummonVal = double.MinValue;
|
var enemySummonVal = double.MinValue;
|
||||||
|
Mobile summonMaster = null;
|
||||||
|
|
||||||
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
|
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
|
||||||
{
|
{
|
||||||
|
|
@ -1005,14 +1006,24 @@ public abstract partial class BaseAI
|
||||||
}
|
}
|
||||||
|
|
||||||
var bc = m as BaseCreature;
|
var bc = m as BaseCreature;
|
||||||
var pm = m as PlayerMobile;
|
|
||||||
|
|
||||||
if (IsInvalidSummonTarget(m, bc, pm) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe)
|
if (IsInvalidSummonTarget(m, bc) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe)
|
||||||
|| IsInvalidFightModeTarget(m, acqType, bc))
|
|| IsInvalidFightModeTarget(m, acqType, bc))
|
||||||
{
|
{
|
||||||
continue;
|
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);
|
var theirVal = Mobile.GetFightModeRanking(m, acqType, bPlayerOnly);
|
||||||
|
|
||||||
if (theirVal > val && Mobile.InLOS(m))
|
if (theirVal > val && Mobile.InLOS(m))
|
||||||
|
|
@ -1020,15 +1031,14 @@ public abstract partial class BaseAI
|
||||||
newFocusMob = m;
|
newFocusMob = m;
|
||||||
val = theirVal;
|
val = theirVal;
|
||||||
}
|
}
|
||||||
else if (Core.AOS && theirVal > enemySummonVal
|
else if (Core.AOS && theirVal > enemySummonVal && Mobile.InLOS(m) && bc is { Summoned: true, Controlled: false })
|
||||||
&& Mobile.InLOS(m) && bc?.Summoned == true && bc.Controlled != true)
|
|
||||||
{
|
{
|
||||||
enemySummonMob = m;
|
enemySummonMob = m;
|
||||||
enemySummonVal = theirVal;
|
enemySummonVal = theirVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Mobile.FocusMob = newFocusMob ?? enemySummonMob;
|
Mobile.FocusMob = newFocusMob ?? enemySummonMob ?? summonMaster;
|
||||||
return Mobile.FocusMob != null;
|
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.Deleted || m.Blessed || m == Mobile || m is BaseFamiliar || !m.Alive || m.IsDeadBondedPet ||
|
||||||
m.AccessLevel > AccessLevel.Player || bPlayerOnly && !m.Player || !Mobile.CanSee(m);
|
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 &&
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Mobile.Summoned || Mobile.SummonMaster == null)
|
var master = Mobile.SummonMaster;
|
||||||
|
|
||||||
|
if (!Mobile.Summoned || master == null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return m == Mobile.SummonMaster || !SpellHelper.ValidIndirectTarget(Mobile.SummonMaster, m) ||
|
if (Mobile.IsAnimatedDead && (m.Player || bc?.IsAnimatedDead == true || bc?.Controlled == true))
|
||||||
Mobile.IsAnimatedDead && (pm != null || 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) =>
|
private bool IsInvalidFactionTarget(Mobile m, bool bFacFriend, bool bFacFoe) =>
|
||||||
|
|
|
||||||
|
|
@ -920,6 +920,10 @@ namespace Server.Mobiles
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public bool IsPrisoner { get; set; }
|
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 bool FollowsAcquireRules => true;
|
||||||
|
|
||||||
public virtual Faction FactionAllegiance => null;
|
public virtual Faction FactionAllegiance => null;
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,8 @@ namespace Server.Mobiles
|
||||||
public override bool BleedImmune => true;
|
public override bool BleedImmune => true;
|
||||||
public override Poison PoisonImmune => Poison.Lethal;
|
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) =>
|
public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) =>
|
||||||
(m.Str + m.Skills.Tactics.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);
|
(m.Str + m.Skills.Tactics.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@ namespace Server.Mobiles
|
||||||
public override bool BleedImmune => true;
|
public override bool BleedImmune => true;
|
||||||
public override Poison PoisonImmune => Poison.Lethal;
|
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) =>
|
public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) =>
|
||||||
(m.Int + m.Skills.Magery.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);
|
(m.Int + m.Skills.Magery.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue