ModernUO/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs
Kamron Batman 6b910ff089
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.
2026-09-23 10:13:34 -07:00

226 lines
7.9 KiB
C#

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