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.
This commit is contained in:
parent
28c38168bc
commit
6b910ff089
4 changed files with 287 additions and 5 deletions
|
|
@ -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<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue