fix: restore pet obedience pacing and reschedule stale AI wakes
Pets slowed dramatically after #2591 (issue #2593): the per-step budget grew from half a think interval to the full RunUO-parity move table, and a guarding pet resolves passive (OnCombatantChange clears Warmode whenever the combatant clears), putting Guard/Come at ~1.05s/step for Medium-bucket pets. Order changes and speed-ups also waited out the previously scheduled AITimer wake, because the timer wheel reads Interval only after the next fire. - CurrentMoveSpeed: a controlled pet executing a master's movement order (Come/Follow/Guard, no combatant) paces steps on the think clock — the wild-creature move table no longer slows obedience. Combat chases and herding keep their own pacing. - AITimer: track the pending wake and reschedule (Stop, Delay = remaining, Start) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through ScheduleNext. New Prod() wakes the AI immediately on player commands, including from a stopped timer (stable claims no longer wait out the random construction stagger). - DoOrderGuard: guard-following routes through WalkMobileRange so it registers a move intent (between-think move wakes) and paths around obstacles instead of bare greedy stepping quantized to the think grid. Closes #2593 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e7f85d404d
commit
1ce2efb1ed
7 changed files with 328 additions and 9 deletions
|
|
@ -0,0 +1,48 @@
|
|||
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 _);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
193
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
193
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObeyingPet_PacesStepsOnThinkClock()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9); // wild-creature table pace; must not slow obedience
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
Assert.Equal(OrderType.Come, pet.ControlOrder);
|
||||
Assert.Equal(0.4, pet.CurrentMoveSpeed);
|
||||
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
Assert.Equal(0.4, pet.CurrentMoveSpeed);
|
||||
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
Assert.Equal(0.4, 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,31 @@ 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.
|
||||
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 +77,54 @@ public sealed class AITimer : Timer
|
|||
if (candidate - _nextThink < 0)
|
||||
{
|
||||
_nextThink = candidate;
|
||||
Reschedule();
|
||||
}
|
||||
}
|
||||
|
||||
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||
// 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
|
||||
}
|
||||
|
||||
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 +178,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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -309,13 +309,13 @@ public abstract partial class BaseAI
|
|||
{
|
||||
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
|
||||
|
||||
var guardLocation = controlMaster.Location;
|
||||
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
|
||||
|
||||
if (distance > 3)
|
||||
{
|
||||
DoMove(Mobile.GetDirectionTo(guardLocation));
|
||||
// Through the approach primitive so guard-following registers a move
|
||||
// intent (between-think move wakes) and paths around obstacles.
|
||||
WalkMobileRange(controlMaster, 1, true, 1, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -751,7 +751,8 @@ 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"/>.
|
||||
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>; a pet
|
||||
/// executing a master's movement order paces on the think clock.
|
||||
/// </summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public double CurrentMoveSpeed
|
||||
|
|
@ -763,6 +764,14 @@ namespace Server.Mobiles
|
|||
return HerdingMoveSpeed;
|
||||
}
|
||||
|
||||
// Obedience is never slowed by the wild-creature move table; combat
|
||||
// chases (combatant set) keep it.
|
||||
if (Controlled && Combatant == null &&
|
||||
ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard)
|
||||
{
|
||||
return _currentSpeed;
|
||||
}
|
||||
|
||||
return _currentSpeed == _activeSpeed ? ActiveMoveSpeed
|
||||
: _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
|
||||
: _currentSpeed;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue