Merge 28c5ea2f68 into 38c74a968b
This commit is contained in:
commit
7d3cdbb3e7
11 changed files with 752 additions and 70 deletions
|
|
@ -0,0 +1,99 @@
|
|||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Guard-order following drives real movement primitives (and may pathfind), so it shares
|
||||
// the pathfinding sequential collection like ApproachTargetTests.
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class GuardFollowTests
|
||||
{
|
||||
[Fact]
|
||||
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
|
||||
pet.SetControlMaster(master);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.AITimer?.Stop(); // drive manually
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
ai.AITimer?.Stop(); // the order change may restart the timer
|
||||
|
||||
var start = pet.Location;
|
||||
ai.NextMove = 0;
|
||||
ai.Obey();
|
||||
|
||||
var moved = pet.Location != start;
|
||||
var hasIntent = ai.TryGetMoveWake(out _);
|
||||
var currentSpeed = pet.CurrentSpeed;
|
||||
var currentMoveSpeed = pet.CurrentMoveSpeed;
|
||||
|
||||
pet.Delete();
|
||||
master.Delete();
|
||||
|
||||
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
|
||||
// Between-think move wakes require a registered move intent; bare greedy stepping
|
||||
// quantizes guard-following to the think grid (issue #2593).
|
||||
Assert.True(hasIntent, "guard-following must register a move intent");
|
||||
|
||||
// RunUO AOS parity: the guard return sprints at the bespoke 0.1, fused to both
|
||||
// clocks, and the per-step speed flip must not undo it (fixture era is EJ).
|
||||
Assert.Equal(0.1, currentSpeed);
|
||||
Assert.Equal(0.1, currentMoveSpeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuardReturn_PreAOS_RunsActive()
|
||||
{
|
||||
var previous = Core.Expansion;
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.UOR;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
pet.SetControlMaster(master);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.AITimer?.Stop();
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
ai.AITimer?.Stop();
|
||||
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
|
||||
|
||||
ai.NextMove = 0;
|
||||
ai.Obey();
|
||||
|
||||
var currentSpeed = pet.CurrentSpeed;
|
||||
|
||||
pet.Delete();
|
||||
master.Delete();
|
||||
|
||||
// No sprint pre-AOS: the return runs organically active.
|
||||
Assert.Equal(0.2, currentSpeed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
140
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Pins guard-order combat semantics (issue #2595): a guarding pet engages and fights
|
||||
// without ever leaving the Guard order, retargets toward the master's closest aggressor,
|
||||
// and stands down deterministically when there is nothing to guard against. The scene
|
||||
// sits on the proven-open (1495..1500, 1600) Trammel segment from ApproachTargetTests;
|
||||
// combat targets are adjacent so no pathfinding runs.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class GuardOrderTests : IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private sealed class AggressorStub : Mobile
|
||||
{
|
||||
public AggressorStub() => Body = 0xC9;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
|
||||
{
|
||||
map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
_created.Add(master);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
|
||||
pet.SetControlMaster(master);
|
||||
_created.Add(pet);
|
||||
|
||||
pet.AIObject.AITimer?.Stop(); // drive manually
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
|
||||
|
||||
return (master, pet);
|
||||
}
|
||||
|
||||
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
|
||||
{
|
||||
var aggr = new AggressorStub();
|
||||
aggr.MoveToWorld(loc, pet.Map);
|
||||
_created.Add(aggr);
|
||||
|
||||
// Guards the test setup itself: the scene must stay on open, LOS-clear terrain
|
||||
// and the combatant assignment must not be vetoed.
|
||||
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
|
||||
|
||||
if (attacking != null)
|
||||
{
|
||||
aggr.Combatant = attacking;
|
||||
Assert.Same(attacking, aggr.Combatant);
|
||||
}
|
||||
|
||||
return aggr;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuardEngage_KeepsGuardOrder()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Same(aggr, pet.Combatant);
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_RetargetsToAggressorClosestToMaster()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
|
||||
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||
|
||||
pet.Combatant = far; // already fighting the far aggressor
|
||||
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
|
||||
// Explicit kill order on a target that then becomes invalid.
|
||||
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
|
||||
pet.ControlTarget = victim;
|
||||
pet.ControlOrder = OrderType.Attack;
|
||||
victim.Hidden = true;
|
||||
|
||||
// A second aggressor is still after the master; FightMode.Closest would chain it.
|
||||
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
|
||||
|
||||
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
|
||||
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
|
||||
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
|
||||
|
||||
Assert.Same(aggr2, pet.Combatant);
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PeacefulGuard_StandsDown()
|
||||
{
|
||||
var (_, pet) = SpawnGuardingPet(out _, out _);
|
||||
Assert.True(pet.Warmode); // the guard order opens in war stance
|
||||
|
||||
pet.AIObject.Obey(); // nothing to guard against
|
||||
|
||||
Assert.False(pet.Warmode);
|
||||
Assert.Null(pet.Combatant);
|
||||
Assert.Null(pet.FocusMob);
|
||||
}
|
||||
}
|
||||
235
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
235
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Pins the pet-obedience pacing policy (issue #2593): a controlled pet executing a
|
||||
// master's movement order paces its steps on the think clock, not the wild-creature
|
||||
// move table; combat chases and herding keep their own pacing.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class PetPacingTests : IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
|
||||
{
|
||||
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
|
||||
_created.Add(pair.master);
|
||||
_created.Add(pair.pet);
|
||||
return pair;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
// Issuing an order sets the think clock organically (RunUO OnCurrentOrderChanged
|
||||
// parity): movement orders run active, resting orders run passive. The move clock
|
||||
// then resolves through the normal classification — no special-casing.
|
||||
[Fact]
|
||||
public void OrderIssue_SetsThinkClock()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
pet.ControlOrder = OrderType.Come;
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed); // organic: verbatim active -> activeMove
|
||||
|
||||
pet.ControlOrder = OrderType.Stay;
|
||||
Assert.Equal(0.4, pet.CurrentSpeed);
|
||||
Assert.Equal(0.9, pet.CurrentMoveSpeed);
|
||||
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
}
|
||||
|
||||
// RunUO AOS parity: a pet following its master sprints — DoOrderFollow writes the
|
||||
// bespoke 0.1, which fuses to both clocks through the normal classification.
|
||||
[Fact]
|
||||
public void FollowMaster_ObeySprints()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.AIObject.AITimer?.Stop();
|
||||
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow; // fixture era is EJ: Core.AOS is true
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Equal(0.1, pet.CurrentSpeed);
|
||||
Assert.Equal(0.1, pet.CurrentMoveSpeed);
|
||||
}
|
||||
|
||||
// A guarding pet at its master's side stays organically active — never the
|
||||
// stale-warmode passive lottery, and no sprint while there is nowhere to go.
|
||||
[Fact]
|
||||
public void GuardAtMastersSide_IsActive()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.AIObject.AITimer?.Stop();
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.AIObject.Obey(); // nothing to guard against, master adjacent
|
||||
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
||||
}
|
||||
|
||||
// Boundary guard: a pet chasing a combatant keeps the move table.
|
||||
[Fact]
|
||||
public void CombatChasingPet_KeepsMoveTable()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
var target = new PetTestStub();
|
||||
target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca);
|
||||
_created.Add(target);
|
||||
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.Combatant = target;
|
||||
pet.SetCurrentSpeedToActive();
|
||||
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
||||
}
|
||||
|
||||
// Boundary guard: herding overrides obedience pacing.
|
||||
[Fact]
|
||||
public void HerdedObeyingPet_KeepsHerdingPace()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.45, 0.9);
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
pet.TargetLocation = new Point2D(1010, 1010);
|
||||
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace
|
||||
}
|
||||
|
||||
private sealed class ThinkProbe : PetTestStub
|
||||
{
|
||||
public int Thinks;
|
||||
|
||||
public override void OnThink()
|
||||
{
|
||||
Thinks++;
|
||||
base.OnThink();
|
||||
}
|
||||
}
|
||||
|
||||
private (PlayerMobile master, ThinkProbe pet) SpawnProbe()
|
||||
{
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
||||
_created.Add(master);
|
||||
|
||||
var pet = new ThinkProbe();
|
||||
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
|
||||
pet.SetControlMaster(master);
|
||||
_created.Add(pet);
|
||||
|
||||
return (master, pet);
|
||||
}
|
||||
|
||||
// Advances simulated time in 8ms lockstep with the wheel, like the real event loop,
|
||||
// so wake schedules and Core.TickCount stay in sync.
|
||||
private static void RunFor(long ms)
|
||||
{
|
||||
var deadline = Core._tickCount + ms;
|
||||
|
||||
while (Core._tickCount < deadline)
|
||||
{
|
||||
Core._tickCount += 8;
|
||||
Timer.Slice(Core._tickCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RunUntil(Func<bool> condition, long maxMs)
|
||||
{
|
||||
var deadline = Core._tickCount + maxMs;
|
||||
|
||||
while (Core._tickCount < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Core._tickCount += 8;
|
||||
Timer.Slice(Core._tickCount);
|
||||
}
|
||||
|
||||
return condition();
|
||||
}
|
||||
|
||||
// Runs past the random spawn-stagger delay to a known think-tick anchor: returns
|
||||
// right after a think fires, with the next one a full passive cadence (0.4s) away.
|
||||
private ThinkProbe SettledProbe(out PlayerMobile master)
|
||||
{
|
||||
Core._tickCount = 0;
|
||||
Timer.Init(0);
|
||||
|
||||
var (m, pet) = SpawnProbe();
|
||||
master = m;
|
||||
pet.ForceIdle = true; // no wandering; pure cadence
|
||||
pet.ControlOrder = OrderType.Stay;
|
||||
|
||||
var settled = RunUntil(() => pet.Thinks >= 2, 8000);
|
||||
Assert.True(settled, "the AI must reach a steady think cadence");
|
||||
|
||||
return pet;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderChange_WakesStaleThinkTimer()
|
||||
{
|
||||
var pet = SettledProbe(out var master);
|
||||
var thinksBefore = pet.Thinks;
|
||||
|
||||
// Mid-wait on the passive cadence: the next think is ~200ms out.
|
||||
RunFor(200);
|
||||
Assert.Equal(thinksBefore, pet.Thinks);
|
||||
|
||||
// The player issues a command; the pet must not wait out the stale wake.
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
|
||||
RunFor(80);
|
||||
Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpeedUp_ReschedulesPendingWake()
|
||||
{
|
||||
var pet = SettledProbe(out _);
|
||||
var thinksBefore = pet.Thinks;
|
||||
|
||||
// Mid-wait on the passive cadence: the next think is ~200ms out.
|
||||
RunFor(200);
|
||||
Assert.Equal(thinksBefore, pet.Thinks);
|
||||
|
||||
// The pet is sped up (e.g. a buff): the next think must move up to the new
|
||||
// 0.1s cadence instead of waiting out the stale 0.4s deadline.
|
||||
pet.CurrentSpeed = 0.1;
|
||||
|
||||
RunFor(120);
|
||||
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
|
||||
}
|
||||
}
|
||||
|
|
@ -118,18 +118,19 @@ public abstract partial class BaseAI
|
|||
|
||||
if (TryMove(d))
|
||||
{
|
||||
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
|
||||
if (Core.AOS && IsFollowingMaster())
|
||||
// An obeying pet's pace is owned by its order handler (issue sets the think
|
||||
// clock; guard/follow write the AOS sprint) — the per-step flip re-derives
|
||||
// speed for wild creatures and combat only, or it would fight those writes.
|
||||
if (!IsObeyingMoveOrder())
|
||||
{
|
||||
Mobile.CurrentSpeed = 0.1;
|
||||
}
|
||||
else if (Mobile.Warmode || Mobile.Combatant != null)
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
if (Mobile.Warmode || Mobile.Combatant != null)
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
}
|
||||
}
|
||||
|
||||
ConsumeMoveBudget();
|
||||
|
|
@ -541,8 +542,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
nextMove = NextMove;
|
||||
|
||||
return (_moveIntentTarget != null || _moveIntentPoint != null) &&
|
||||
Core.TickCount - _moveIntentExpire < 0;
|
||||
return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -599,6 +599,14 @@ public abstract partial class BaseAI
|
|||
Mobile.ControlTarget == Mobile.ControlMaster &&
|
||||
Mobile.Combatant == null;
|
||||
|
||||
// A pet executing a master's movement order with no combat; its order handler owns
|
||||
// the speed clocks (mirrors RunUO's OnCurrentOrderChanged/DoOrder* speed writes).
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool IsObeyingMoveOrder() =>
|
||||
Mobile.Controlled &&
|
||||
Mobile.Combatant == null &&
|
||||
Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard;
|
||||
|
||||
private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range)
|
||||
{
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(target);
|
||||
|
|
@ -649,14 +657,12 @@ public abstract partial class BaseAI
|
|||
{
|
||||
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
|
||||
|
||||
var shouldRun = run && iCurrDist > 5;
|
||||
|
||||
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax))
|
||||
if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -667,18 +673,19 @@ public abstract partial class BaseAI
|
|||
return dist >= iWantDistMin && dist <= iWantDistMax;
|
||||
}
|
||||
|
||||
// The caller's run flag is honored as-is: it only sets the client-side animation
|
||||
// (server pace is the move budget), and the callers that pass anything but false —
|
||||
// follow, guard, clone — gate it on their own distance thresholds.
|
||||
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
|
||||
{
|
||||
var shouldRun = run && iCurrDist > 5;
|
||||
|
||||
if (iCurrDist > iWantDistMax)
|
||||
{
|
||||
// Too far: approach via the centralized progress-based primitive.
|
||||
return ApproachTarget(m, shouldRun, iWantDistMax);
|
||||
return ApproachTarget(m, run, iWantDistMax);
|
||||
}
|
||||
|
||||
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
||||
if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true))
|
||||
if (DoMove(m.GetDirectionTo(Mobile, run), true))
|
||||
{
|
||||
Path = null;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ public sealed class AITimer : Timer
|
|||
{
|
||||
private readonly BaseAI _owner;
|
||||
private long _nextThink;
|
||||
private long _nextWake; // when the pending wheel entry fires; the wheel cannot tell us
|
||||
private bool _inTick;
|
||||
private int _detectHiddenMinDelay;
|
||||
private int _detectHiddenMaxDelay;
|
||||
|
||||
|
|
@ -40,8 +42,34 @@ public sealed class AITimer : Timer
|
|||
public void Activate()
|
||||
{
|
||||
_nextThink = Core.TickCount;
|
||||
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||
|
||||
if (Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Start(); // keeps the current Delay: the construction stagger for spawn/sector wakes
|
||||
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
|
||||
}
|
||||
|
||||
// A fresh command must not wait out the previous cadence: think now. Pets are exempt
|
||||
// from sector deactivation, so dropping the stagger Delay here cannot bunch sector wakes.
|
||||
// Spam-safe: at most one think per command and no compounding, and a think grants no
|
||||
// action — steps (NextMove budget), swings, casts, abilities and detect-hidden are all
|
||||
// gated by their own budgets/timers that this never touches.
|
||||
public void Prod()
|
||||
{
|
||||
_nextThink = Core.TickCount;
|
||||
|
||||
if (Running)
|
||||
{
|
||||
Reschedule();
|
||||
return;
|
||||
}
|
||||
|
||||
Delay = TimeSpan.Zero;
|
||||
Start();
|
||||
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
|
||||
}
|
||||
|
||||
// A speed-up must not wait out a stale, longer think deadline.
|
||||
|
|
@ -52,12 +80,54 @@ public sealed class AITimer : Timer
|
|||
if (candidate - _nextThink < 0)
|
||||
{
|
||||
_nextThink = candidate;
|
||||
Reschedule();
|
||||
}
|
||||
}
|
||||
|
||||
// Restarts the timer when the new earliest deadline lands before the pending wake.
|
||||
// The wheel reads Interval only after the next fire, so moving a pending wake earlier
|
||||
// requires Stop, Delay = remaining, Start.
|
||||
private void Reschedule()
|
||||
{
|
||||
if (_inTick || !Running)
|
||||
{
|
||||
return; // ScheduleNext reads the updated deadlines at tick end
|
||||
}
|
||||
|
||||
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||
var now = Core.TickCount;
|
||||
var deadline = _nextThink;
|
||||
|
||||
if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0)
|
||||
{
|
||||
deadline = nextMove;
|
||||
}
|
||||
|
||||
if (deadline - _nextWake >= 0)
|
||||
{
|
||||
return; // the pending wake is already at or before the new deadline
|
||||
}
|
||||
|
||||
Stop();
|
||||
Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now));
|
||||
Start();
|
||||
_nextWake = now + (long)Delay.TotalMilliseconds;
|
||||
}
|
||||
|
||||
protected override void OnTick()
|
||||
{
|
||||
_inTick = true;
|
||||
|
||||
try
|
||||
{
|
||||
OnTickCore();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inTick = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTickCore()
|
||||
{
|
||||
if (ShouldStop())
|
||||
{
|
||||
|
|
@ -111,6 +181,7 @@ public sealed class AITimer : Timer
|
|||
|
||||
// The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn.
|
||||
Interval = TimeSpan.FromMilliseconds(delay);
|
||||
_nextWake = now + (long)Interval.TotalMilliseconds;
|
||||
}
|
||||
|
||||
private bool ShouldStop()
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ public abstract partial class BaseAI
|
|||
|
||||
if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active)
|
||||
{
|
||||
AITimer.Start();
|
||||
AITimer.Activate();
|
||||
}
|
||||
|
||||
if (Action != ActionType.Wander)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ public abstract partial class BaseAI
|
|||
return;
|
||||
}
|
||||
|
||||
Activate();
|
||||
// A fresh command wakes the AI immediately — never wait out the previous cadence.
|
||||
AITimer.Prod();
|
||||
|
||||
switch (Mobile.ControlOrder)
|
||||
{
|
||||
|
|
@ -36,6 +37,11 @@ public abstract partial class BaseAI
|
|||
break;
|
||||
}
|
||||
case OrderType.Come:
|
||||
{
|
||||
// RunUO parity: movement orders run the active think clock.
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
break;
|
||||
}
|
||||
case OrderType.Drop:
|
||||
case OrderType.Friend:
|
||||
case OrderType.Unfriend:
|
||||
|
|
@ -135,6 +141,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive
|
||||
}
|
||||
|
||||
private void HandleTransferOrder()
|
||||
|
|
@ -148,6 +155,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
|
@ -162,9 +170,17 @@ public abstract partial class BaseAI
|
|||
_commandIssuer?.RevealingAction();
|
||||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = true;
|
||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
|
||||
// ~1_NAME~ is now guarding you.
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
|
||||
// Only a freshly issued command plays the flourish; resuming the persistent
|
||||
// order after an explicit attack must not replay it after every kill.
|
||||
if (!_resolvingOrder)
|
||||
{
|
||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
|
||||
// ~1_NAME~ is now guarding you.
|
||||
}
|
||||
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +207,7 @@ public abstract partial class BaseAI
|
|||
}
|
||||
|
||||
Mobile.Warmode = true;
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
|
@ -206,6 +223,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToActive(); // RunUO parity: movement orders run active
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
|
@ -221,6 +239,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
|
||||
|
|
|
|||
|
|
@ -128,6 +128,13 @@ public abstract partial class BaseAI
|
|||
|
||||
this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}.");
|
||||
|
||||
// RunUO AOS parity: a pet sprints after its master (bespoke 0.1 fuses to both
|
||||
// clocks); other targets keep the active pace the order issue set.
|
||||
if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null)
|
||||
{
|
||||
Mobile.CurrentSpeed = 0.1;
|
||||
}
|
||||
|
||||
if (currentDistance > 1)
|
||||
{
|
||||
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
||||
|
|
@ -291,14 +298,15 @@ public abstract partial class BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
FindCombatant();
|
||||
var combatant = FindGuardTarget();
|
||||
|
||||
if (IsValidCombatant(Mobile.Combatant))
|
||||
if (combatant != null)
|
||||
{
|
||||
var combatant = Mobile.Combatant;
|
||||
|
||||
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
|
||||
|
||||
// Engage without leaving the Guard order (#2595): the (guarding)/guarded
|
||||
// tags persist, recall/gate keeps the pet, and the per-tick scan retargets
|
||||
// toward the master's closest aggressor for the whole fight.
|
||||
Mobile.Combatant = combatant;
|
||||
Mobile.FocusMob = combatant;
|
||||
Action = ActionType.Combat;
|
||||
|
|
@ -309,16 +317,33 @@ public abstract partial class BaseAI
|
|||
{
|
||||
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
|
||||
|
||||
var guardLocation = controlMaster.Location;
|
||||
// Stand down deterministically: a stale Warmode otherwise leaves the return
|
||||
// pace active or passive by combat history.
|
||||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
|
||||
|
||||
if (distance > 3)
|
||||
{
|
||||
DoMove(Mobile.GetDirectionTo(guardLocation));
|
||||
// RunUO parity: the AOS return sprints (bespoke 0.1 fuses to both
|
||||
// clocks); earlier eras run active. Through the approach primitive so
|
||||
// guard-following registers a move intent and paths around obstacles.
|
||||
if (Core.AOS)
|
||||
{
|
||||
Mobile.CurrentSpeed = 0.1;
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
|
||||
WalkMobileRange(controlMaster, 1, true, 1, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
|
||||
WalkRandom(3, 1, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -359,67 +384,86 @@ public abstract partial class BaseAI
|
|||
Mobile.ControlTarget = Mobile.ControlMaster;
|
||||
ResumePersistentOrder();
|
||||
|
||||
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
|
||||
// A resumed Guard engages through its own scan without ever leaving the order;
|
||||
// only non-guard fallbacks chain the next aggressor through an explicit Attack.
|
||||
if (Mobile.ControlOrder == OrderType.Guard ||
|
||||
Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor))
|
||||
{
|
||||
FindCombatant();
|
||||
return;
|
||||
}
|
||||
|
||||
var next = FindGuardTarget();
|
||||
|
||||
if (next != null)
|
||||
{
|
||||
Mobile.ControlTarget = next;
|
||||
Mobile.ControlOrder = OrderType.Attack;
|
||||
Mobile.Combatant = next;
|
||||
|
||||
this.DebugSayFormatted($"{next.Name} is still hostile! Engaging...");
|
||||
|
||||
Think();
|
||||
}
|
||||
}
|
||||
|
||||
private void FindCombatant()
|
||||
/// <summary>
|
||||
/// Selects the aggressor a pet should defend against, preferring whichever is
|
||||
/// closest to the master (RunUO guard parity — a guarding pet retargets to protect
|
||||
/// its owner). The current combatant is the baseline and is kept unless a strictly
|
||||
/// closer aggressor exists. Pure selection: never mutates any order state.
|
||||
/// </summary>
|
||||
private Mobile FindGuardTarget()
|
||||
{
|
||||
var controlMaster = Mobile.ControlMaster;
|
||||
var anchor = controlMaster ?? Mobile;
|
||||
|
||||
var current = Mobile.Combatant;
|
||||
var best = current != controlMaster && IsValidCombatant(current) ? current : null;
|
||||
var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue;
|
||||
|
||||
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
||||
{
|
||||
if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive)
|
||||
if (aggr == best || aggr == Mobile || aggr == controlMaster ||
|
||||
aggr.IsDeadBondedPet || !aggr.Alive ||
|
||||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isAttackingPet = aggr.Combatant == Mobile;
|
||||
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
|
||||
var dist = aggr.GetDistanceToSqrt(anchor);
|
||||
|
||||
if (isAttackingPet || isAttackingMaster)
|
||||
if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
|
||||
{
|
||||
if (Mobile.InLOS(aggr))
|
||||
{
|
||||
Mobile.ControlTarget = aggr;
|
||||
Mobile.ControlOrder = OrderType.Attack;
|
||||
Mobile.Combatant = aggr;
|
||||
|
||||
var target = isAttackingMaster ? "master" : "me";
|
||||
this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging...");
|
||||
|
||||
Think();
|
||||
return;
|
||||
}
|
||||
best = aggr;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
if (controlMaster?.Aggressors != null)
|
||||
{
|
||||
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
|
||||
{
|
||||
var aggressor = controlMaster.Aggressors[i].Attacker;
|
||||
var aggressors = controlMaster?.Aggressors;
|
||||
|
||||
if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet)
|
||||
if (aggressors != null)
|
||||
{
|
||||
for (var i = 0; i < aggressors.Count; i++)
|
||||
{
|
||||
var aggressor = aggressors[i].Attacker;
|
||||
|
||||
if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive ||
|
||||
aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
||||
var dist = aggressor.GetDistanceToSqrt(anchor);
|
||||
|
||||
if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
||||
{
|
||||
Mobile.ControlTarget = aggressor;
|
||||
Mobile.ControlOrder = OrderType.Attack;
|
||||
Mobile.Combatant = aggressor;
|
||||
|
||||
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
|
||||
|
||||
Think();
|
||||
return;
|
||||
best = aggressor;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public virtual bool DoOrderRelease()
|
||||
|
|
|
|||
|
|
@ -750,8 +750,9 @@ namespace Server.Mobiles
|
|||
|
||||
/// <summary>
|
||||
/// Resolved seconds per step: a verbatim active/passive <see cref="CurrentSpeed"/>
|
||||
/// maps to the matching movement value; a bespoke pace stays fused to both clocks.
|
||||
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>.
|
||||
/// maps to the matching movement value; a bespoke pace (e.g. the AOS 0.1 sprint
|
||||
/// pet orders write) stays fused to both clocks. A herded creature is always
|
||||
/// driven at <see cref="HerdingMoveSpeed"/>.
|
||||
/// </summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public double CurrentMoveSpeed
|
||||
|
|
@ -845,6 +846,10 @@ namespace Server.Mobiles
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Point3D ControlDest { get; set; }
|
||||
|
||||
// Deliberately fires on every assignment, not just changes: a reissued order is a
|
||||
// command ("all attack" retargets via ControlTarget, "all follow" breaks off combat,
|
||||
// "all stay" re-anchors Home) and must run its handler and wake the AI. Handlers
|
||||
// needing change-detection compare against the previous order they receive.
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public OrderType ControlOrder
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ description: >
|
|||
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
|
||||
AND clears move overrides, `SetMoveSpeed()` sets move only -- see
|
||||
`dev-docs/content-patterns.md` § Creature Speeds
|
||||
8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the
|
||||
think cadence (player commands prod it; speed-ups reschedule it). Gate consequential
|
||||
work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call
|
||||
random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the
|
||||
trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a
|
||||
zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see
|
||||
`dev-docs/content-patterns.md` § OnThink: the excess-call contract
|
||||
|
||||
## New Item Template
|
||||
|
||||
|
|
|
|||
|
|
@ -283,6 +283,61 @@ ClearMoveSpeed(); // back to inheriting the think clock
|
|||
All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance
|
||||
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
|
||||
|
||||
### OnThink: the excess-call contract
|
||||
|
||||
`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the
|
||||
think cadence (`CurrentSpeed`), but it can and does fire more often: a player command
|
||||
wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and
|
||||
players run command macros that drive extra thinks deliberately (order spam is spam-safe
|
||||
by design — reaction, never action). RunUO had the same property (its timer restarted
|
||||
with a random delay on every speed change), so this has never been a fixed-rate callback.
|
||||
|
||||
**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant
|
||||
an extra action:
|
||||
|
||||
- Gate consequential work on its own deadline field, compared in subtraction form
|
||||
(`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent.
|
||||
- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll —
|
||||
its frequency then scales with think rate, which players can influence. Per-call rolls
|
||||
are acceptable only for pure cosmetics (idle animations, flavor sounds).
|
||||
- The engine already gates the expensive things: steps (the `NextMove` budget), weapon
|
||||
swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal,
|
||||
rummage, aura) all carry their own clocks. Follow that pattern.
|
||||
|
||||
```csharp
|
||||
private long _nextSpecial;
|
||||
|
||||
public override void OnThink()
|
||||
{
|
||||
base.OnThink();
|
||||
|
||||
if (Core.TickCount - _nextSpecial >= 0)
|
||||
{
|
||||
DoSpecial();
|
||||
_nextSpecial = Core.TickCount + 5000; // the real rate limit lives here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MonsterAbility: same contract
|
||||
|
||||
`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and
|
||||
`CombatAction`-triggered abilities, so abilities live under the same rule:
|
||||
|
||||
- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds
|
||||
no matter how often thinks fire. Always give a triggered ability a real cooldown.
|
||||
- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected
|
||||
trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as
|
||||
the rate limiter, and keep cooldowns long relative to the think interval so the jitter
|
||||
stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between
|
||||
natural and spammed think rates).
|
||||
- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled
|
||||
think that passes its chance — only ever correct for passive alteration hooks, never
|
||||
for `Think`/`CombatAction` triggers.
|
||||
- An ability that breaks pet orders (fear-style effects) must own its duration explicitly
|
||||
(a hold state, or a "refuses orders until" deadline checked in the order handlers) —
|
||||
pets react to re-issued commands immediately, so think latency is not a hold.
|
||||
|
||||
---
|
||||
|
||||
## New Spell
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue