From 6b910ff0891135158d5ca8ab28dbd549874acd7b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:13:34 -0700 Subject: [PATCH] fix(ai): guarding pets never target their owner or the owner's pets (#2665) Supersedes #2657 (rebased onto #2662, which the original branch conflicted with). Credit to @MithrilHammer for the diagnosis, the fix and the regression tests; their three commits are kept as authored. Fixes #2656. ## Root cause When a guarding pet's combatant hides or becomes invalid, its AI calls `AcquireFocusMob` to find a new target. A controlled creature with no `ControlTarget` (Guard normally has none) fell out of `HandleControlled` and into the wild-creature scan, where `IsEnemy` treats any player as an enemy. With `FightMode.Closest`, the owner won. The fall-through dates to #2232. RunUO never let a controlled creature reach the wild scan. ## Changes - `AcquireFocusMob`: a controlled creature returns whatever `HandleControlled` decides. Guard uses `FindGuardTarget`, Attack uses its `ControlTarget`, and every other order acquires nothing. - `FindGuardTarget` skips guard allies: the master and creatures the master controls, including controlled summons. Uncontrolled summons (energy vortex, blade spirits) attack their caster, so a guard still defends against them, as in RunUO. - Friendly fire: `BaseAI.OnAggressiveAction` stands a guard down when an ally is the aggressor. `BaseCreature.Combatant` refuses an ally while guarding, because `Mobile.AggressiveAction` assigns the first aggressor as `Combatant` before any retaliation policy runs. The veto sits on `Combatant` rather than `CanBeHarmful`, so what a pet can harm doesn't depend on its current order. An explicit Attack order and bard provocation can still target an ally. ## Tests 16 regression cases in `GuardFriendlyFireTests` (skipped without client map data), including a real `WhiteWyrm` reproduction. `UOContent.Tests`: 1142 passed, 2 skipped, 0 failed on Windows. --- .../Mobiles/AI/GuardFriendlyFireTests.cs | 226 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 31 ++- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 15 +- Projects/UOContent/Mobiles/BaseCreature.cs | 20 ++ 4 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs new file mode 100644 index 000000000..8994bd391 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class GuardFriendlyFireTests : IDisposable +{ + private readonly List _created = []; + + private (PlayerMobile owner, PetTestStub pet) Spawn() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + var z = Map.Felucca.GetAverageZ(1500, 1600); + var owner = new PlayerMobile(); + owner.MoveToWorld(new Point3D(1500, 1600, z), Map.Felucca); + _created.Add(owner); + var pet = OtherPet(owner, new Point3D(1499, 1600, z)); + pet.IssueOrder(OrderType.Guard, owner); + pet.AIObject.AITimer.Stop(); + return (owner, pet); + } + + private PetTestStub OtherPet(PlayerMobile owner, Point3D location) + { + var pet = new PetTestStub(); + pet.MoveToWorld(location, owner.Map); + pet.SetControlMaster(owner); + pet.AIObject.AITimer.Stop(); + _created.Add(pet); + return pet; + } + + [SkippableFact] + public void GuardFallbackDoesNotAcquireItsOwner() + { + var (owner, pet) = Spawn(); + Assert.Null(pet.ControlTarget); // speech dispatch clears this; guard must not fall through to wild acquisition. + Assert.True(pet.CanSee(owner)); + Assert.True(pet.InLOS(owner)); + pet.NextReacquireTime = Core.TickCount - 1; + Assert.False(pet.AIObject.AcquireFocusMob(pet.RangePerception, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + Assert.Null(pet.Combatant); + } + + [SkippableFact] + public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + pet.IssueOrder(OrderType.Attack, owner, sibling); + Assert.Same(sibling, pet.Combatant); + pet.IssueOrder(OrderType.Guard, owner); + pet.AIObject.DoOrderGuard(); + Assert.Null(pet.Combatant); + Assert.Null(pet.FocusMob); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [SkippableFact] + public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage() + { + var (owner, pet) = Spawn(); + pet.AggressiveAction(owner, false); + Assert.Null(pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [SkippableFact] + public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + pet.AggressiveAction(sibling, false); + Assert.Null(pet.Combatant); + } + + [SkippableFact] + public void GuardReacquiresAnEnemyAttackingItsOwner() + { + var (owner, pet) = Spawn(); + var enemy = new PetTestStub(); + _created.Add(enemy); + enemy.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + enemy.AIObject.AITimer.Stop(); + enemy.Combatant = owner; + Assert.Same(owner, enemy.Combatant); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(enemy, pet.FocusMob); + } + + [SkippableFact] + public void GuardStillDefendsAgainstAnotherPlayersPet() + { + var (owner, pet) = Spawn(); + var enemyOwner = new PlayerMobile(); + _created.Add(enemyOwner); + enemyOwner.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); + var enemy = OtherPet(enemyOwner, new Point3D(1498, 1600, owner.Z)); + enemy.IssueOrder(OrderType.Attack, enemyOwner, owner); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(enemy, pet.FocusMob); + } + + [SkippableFact] + public void GuardIgnoresSiblingInOwnersAggressorHistory() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + owner.AggressiveAction(sibling, false); + Assert.Contains(owner.Aggressors, entry => entry.Attacker == sibling); + Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + } + + [SkippableFact] + public void GuardIgnoresOwnControlledSummonAttackingOwner() + { + var (owner, pet) = Spawn(); + var summon = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + summon.Summoned = true; + summon.SummonMaster = owner; + summon.Combatant = owner; + Assert.Same(owner, summon.Combatant); + pet.AggressiveAction(summon, false); + Assert.Null(pet.Combatant); + Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + } + + [SkippableFact] + public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner() + { + var (owner, pet) = Spawn(); + var vortex = new PetTestStub { Summoned = true, SummonMaster = owner }; + _created.Add(vortex); + vortex.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + vortex.AIObject.AITimer.Stop(); + vortex.Combatant = owner; + Assert.Same(owner, vortex.Combatant); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(vortex, pet.FocusMob); + } + + [SkippableFact] + public void FriendlyDamageDoesNotInterruptGuardCombat() + { + var (owner, pet) = Spawn(); + var enemy = new PetTestStub(); + _created.Add(enemy); + enemy.MoveToWorld(new Point3D(1496, 1600, owner.Z), owner.Map); + enemy.AIObject.AITimer.Stop(); + pet.Combatant = enemy; + pet.AggressiveAction(owner, false); + Assert.Same(enemy, pet.Combatant); + } + + [SkippableFact] + public void ExplicitAttackStillAcquiresTheCommandedTarget() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + pet.IssueOrder(OrderType.Attack, owner, sibling); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(sibling, pet.FocusMob); + } + + [SkippableFact] + public void BardProvocationStillOverridesGuard() + { + var (owner, pet) = Spawn(); + pet.BardProvoked = true; + pet.BardTarget = owner; + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(owner, pet.FocusMob); + } + + [SkippableTheory] + [InlineData(OrderType.Follow)] + [InlineData(OrderType.Come)] + [InlineData(OrderType.Stay)] + public void NonCombatOrdersDoNotAcquireTheirFriendlyTarget(OrderType order) + { + var (owner, pet) = Spawn(); + pet.IssueOrder(order, owner, owner); + Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + } + + [SkippableFact] + public void ActualWhiteWyrmDoesNotTurnOnOwnerWhenEnemyDisappears() + { + var (owner, _) = Spawn(); + var wyrm = new WhiteWyrm(); + _created.Add(wyrm); + wyrm.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + wyrm.SetControlMaster(owner); + wyrm.IssueOrder(OrderType.Guard, owner); + wyrm.AIObject.AITimer.Stop(); + var enemy = new PetTestStub(); + _created.Add(enemy); + enemy.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); + enemy.AIObject.AITimer.Stop(); + wyrm.Combatant = enemy; + enemy.Hidden = true; + wyrm.NextReacquireTime = Core.TickCount - 1; + Assert.IsType(wyrm.AIObject); + wyrm.AIObject.DoActionCombat(); + Assert.NotSame(owner, wyrm.Combatant); + Assert.Null(wyrm.FocusMob); + wyrm.AIObject.DoOrderGuard(); + Assert.Null(wyrm.Combatant); + } + + public void Dispose() + { + 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 6e5f1c295..f894a015d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -137,6 +137,12 @@ public abstract partial class BaseAI /// public virtual bool OnAggressiveAction(Mobile aggressor) { + if (Mobile.RefusesGuardTarget(aggressor)) + { + DebugSay("Friendly fire; I keep guarding."); + return false; + } + // Only a creature somebody can command has been told anything; wild creatures rest on None. var toldToStandDown = Mobile.Controlled && Mobile.ControlMaster != null && Mobile.Commandable && IsStandDownOrder(Mobile.ControlOrder); @@ -856,7 +862,19 @@ public abstract partial class BaseAI return false; } - if (HandleBardProvoked() || HandleControlled() || HandleConstantFocus()) + if (HandleBardProvoked()) + { + return true; + } + + // A controlled creature must not fall through to wild target selection when + // its order has no target. Guard in particular normally has no ControlTarget. + if (Mobile.Controlled) + { + return HandleControlled(); + } + + if (HandleConstantFocus()) { return true; } @@ -911,8 +929,17 @@ public abstract partial class BaseAI private bool HandleControlled() { - if (!Mobile.Controlled) + if (Mobile.ControlOrder == OrderType.Guard) { + Mobile.FocusMob = FindGuardTarget(); + return Mobile.FocusMob != null; + } + + // Follow and other non-combat orders may target the owner or a friend. + // Only an explicit attack order supplies a combat target. + if (Mobile.ControlOrder != OrderType.Attack) + { + Mobile.FocusMob = null; return false; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1a166d493..dc605b976 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -769,6 +769,15 @@ public abstract partial class BaseAI } } + // Uncontrolled summons (energy vortex, blade spirits) turn on their caster, so a guard still defends against them. + internal bool IsGuardAlly(Mobile target) + { + var master = Mobile.ControlMaster; + + return target == Mobile || master != null && + (target == master || target is BaseCreature { Controlled: true } creature && creature.ControlMaster == master); + } + /// /// Selects the aggressor closest to the master. The current combatant is kept /// unless a strictly closer one exists. Never mutates order state. @@ -779,12 +788,12 @@ public abstract partial class BaseAI var anchor = controlMaster ?? Mobile; var current = Mobile.Combatant; - var best = current != controlMaster && IsValidCombatant(current) ? current : null; + var best = !IsGuardAlly(current) && IsValidCombatant(current) ? current : null; var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (aggr == best || aggr == Mobile || aggr == controlMaster || + if (aggr == best || IsGuardAlly(aggr) || aggr.IsDeadBondedPet || !aggr.Alive || aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) { @@ -808,7 +817,7 @@ public abstract partial class BaseAI { var aggressor = aggressors[i].Attacker; - if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || + if (aggressor == best || aggressor?.Deleted != false || IsGuardAlly(aggressor) || !aggressor.Alive || aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) { continue; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f159c70e1..cf55821ca 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3650,6 +3650,26 @@ namespace Server.Mobiles m?.InvalidateProperties(); } + // A guard never fights its master or the master's other pets. An explicit Attack order and bard + // provocation still may. + public bool RefusesGuardTarget(Mobile target) => + _controlled && _controlOrder == OrderType.Guard && !BardProvoked && AIObject?.IsGuardAlly(target) == true; + + // Mobile.AggressiveAction assigns the first aggressor as Combatant before any retaliation policy runs. + public override Mobile Combatant + { + get => base.Combatant; + set + { + if (value != null && RefusesGuardTarget(value)) + { + return; + } + + base.Combatant = value; + } + } + public override bool CanBeHarmful(Mobile target, bool message, bool ignoreOurBlessedness) { if (target is BaseFactionGuard)