refactor: pet order speeds flow organically through the order handlers

Streamlines the two sprint mechanisms (the CurrentMoveSpeed guard carve-out
and DoMoveImpl's follow-master 0.1 write) into one RunUO-parity model:

- Order handlers own obedience speed, mirroring RunUO's OnCurrentOrderChanged
  and DoOrder* writes: issuing a movement order (Come/Follow/Guard/Attack)
  sets the active think clock, resting orders (Stay/None/Transfer) set
  passive, and the guard/follow peaceful branches write RunUO's AOS 0.1
  sprint (guard's else-branch had the identical `if (Core.AOS)
  CurrentSpeed = 0.1` as follow). Pre-AOS guard returns run active.
- CurrentMoveSpeed reverts to pure herding + classification — the bespoke
  0.1 fuses to both clocks through the existing rule, so the sprint needs no
  special case and the obedience branch is deleted.
- DoMoveImpl's per-step speed flip skips obeying pets (their handler owns the
  pace; per-step passive flips would fight it) and loses its 0.1 write.
  Combat still re-derives organically via warmode/combatant.

Net pacing (Medium bucket): guard/follow AOS returns sprint 0.1 fused (RunUO
parity, guard was previously move-clock-only), Come and friend-follow pace at
activeMove (0.45, ~= the pre-#2591 feel), and the stale-Warmode active/
passive lottery is gone everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-25 09:43:02 -07:00
parent c411a035e4
commit 28c5ea2f68
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
6 changed files with 149 additions and 67 deletions

View file

@ -36,6 +36,8 @@ public class GuardFollowTests
var moved = pet.Location != start;
var hasIntent = ai.TryGetMoveWake(out _);
var currentSpeed = pet.CurrentSpeed;
var currentMoveSpeed = pet.CurrentMoveSpeed;
pet.Delete();
master.Delete();
@ -44,5 +46,54 @@ public class GuardFollowTests
// 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;
}
}
}

View file

@ -32,58 +32,64 @@ public class PetPacingTests : IDisposable
_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 ObeyingPet_PacesStepsOnThinkClock()
public void OrderIssue_SetsThinkClock()
{
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.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.4, pet.CurrentMoveSpeed);
}
// A guarding pet with nothing to fight returns to its master at the follow sprint
// pace (RunUO guard parity) while its think cadence stays untouched.
[Fact]
public void GuardReturn_SprintsOnMoveClock()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Guard; // fixture era is EJ: Core.AOS is true
pet.ControlOrder = OrderType.Come;
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.3, pet.CurrentMoveSpeed); // organic: verbatim active -> activeMove
Assert.Equal(0.1, pet.CurrentMoveSpeed);
Assert.Equal(0.4, pet.CurrentSpeed); // think clock unaffected
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);
}
// Boundary guard: pre-AOS eras have no sprint — guard paces on the think clock.
// 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 GuardReturn_PreAOS_PacesThinkClock()
public void FollowMaster_ObeySprints()
{
var previous = Core.Expansion;
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.AIObject.AITimer?.Stop();
try
{
Core.Expansion = Expansion.UOR;
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow; // fixture era is EJ: Core.AOS is true
pet.AIObject.Obey();
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.SetCurrentSpeedToPassive();
Assert.Equal(0.1, pet.CurrentSpeed);
Assert.Equal(0.1, pet.CurrentMoveSpeed);
}
pet.ControlOrder = OrderType.Guard;
// 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();
Assert.Equal(0.4, pet.CurrentMoveSpeed);
}
finally
{
Core.Expansion = previous;
}
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.

View file

@ -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);

View file

@ -37,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:
@ -136,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()
@ -149,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;
}
@ -163,6 +170,7 @@ public abstract partial class BaseAI
_commandIssuer?.RevealingAction();
Mobile.FocusMob = null;
Mobile.Warmode = true;
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.
@ -199,6 +207,7 @@ public abstract partial class BaseAI
}
Mobile.Warmode = true;
Mobile.SetCurrentSpeedToActive();
Mobile.PlaySound(Mobile.GetAttackSound());
_commandIssuer = null;
}
@ -214,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;
}
@ -229,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.

View file

@ -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);
@ -320,12 +327,23 @@ public abstract partial class BaseAI
if (distance > 3)
{
// Through the approach primitive so guard-following registers a move
// intent (between-think move wakes) and paths around obstacles.
// 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);
}
}

View file

@ -750,9 +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"/>; a pet
/// executing a master's movement order paces on the think clock.
/// 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
@ -764,18 +764,6 @@ namespace Server.Mobiles
return HerdingMoveSpeed;
}
// Obedience is never slowed by the wild-creature move table; combat
// chases (combatant set) keep it. A guarding pet returns to its master
// at the follow sprint pace (RunUO guard parity) with its think cadence
// untouched.
if (Controlled && Combatant == null &&
ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard)
{
return Core.AOS && ControlOrder == OrderType.Guard
? Math.Min(_currentSpeed, 0.1)
: _currentSpeed;
}
return _currentSpeed == _activeSpeed ? ActiveMoveSpeed
: _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
: _currentSpeed;