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:
parent
c411a035e4
commit
28c5ea2f68
6 changed files with 149 additions and 67 deletions
|
|
@ -36,6 +36,8 @@ public class GuardFollowTests
|
||||||
|
|
||||||
var moved = pet.Location != start;
|
var moved = pet.Location != start;
|
||||||
var hasIntent = ai.TryGetMoveWake(out _);
|
var hasIntent = ai.TryGetMoveWake(out _);
|
||||||
|
var currentSpeed = pet.CurrentSpeed;
|
||||||
|
var currentMoveSpeed = pet.CurrentMoveSpeed;
|
||||||
|
|
||||||
pet.Delete();
|
pet.Delete();
|
||||||
master.Delete();
|
master.Delete();
|
||||||
|
|
@ -44,5 +46,54 @@ public class GuardFollowTests
|
||||||
// Between-think move wakes require a registered move intent; bare greedy stepping
|
// Between-think move wakes require a registered move intent; bare greedy stepping
|
||||||
// quantizes guard-following to the think grid (issue #2593).
|
// quantizes guard-following to the think grid (issue #2593).
|
||||||
Assert.True(hasIntent, "guard-following must register a move intent");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,58 +32,64 @@ public class PetPacingTests : IDisposable
|
||||||
_created.Clear();
|
_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]
|
[Fact]
|
||||||
public void ObeyingPet_PacesStepsOnThinkClock()
|
public void OrderIssue_SetsThinkClock()
|
||||||
{
|
{
|
||||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
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.SetMoveSpeed(0.3, 0.9);
|
||||||
pet.SetCurrentSpeedToPassive();
|
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);
|
pet.ControlOrder = OrderType.Stay;
|
||||||
Assert.Equal(0.4, pet.CurrentSpeed); // think clock unaffected
|
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]
|
[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
|
pet.ControlTarget = master;
|
||||||
{
|
pet.ControlOrder = OrderType.Follow; // fixture era is EJ: Core.AOS is true
|
||||||
Core.Expansion = Expansion.UOR;
|
pet.AIObject.Obey();
|
||||||
|
|
||||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
Assert.Equal(0.1, pet.CurrentSpeed);
|
||||||
pet.SetMoveSpeed(0.3, 0.9);
|
Assert.Equal(0.1, pet.CurrentMoveSpeed);
|
||||||
pet.SetCurrentSpeedToPassive();
|
}
|
||||||
|
|
||||||
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);
|
pet.ControlOrder = OrderType.Guard;
|
||||||
}
|
pet.AIObject.Obey(); // nothing to guard against, master adjacent
|
||||||
finally
|
|
||||||
{
|
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||||
Core.Expansion = previous;
|
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boundary guard: a pet chasing a combatant keeps the move table.
|
// Boundary guard: a pet chasing a combatant keeps the move table.
|
||||||
|
|
|
||||||
|
|
@ -118,18 +118,19 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
if (TryMove(d))
|
if (TryMove(d))
|
||||||
{
|
{
|
||||||
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
|
// An obeying pet's pace is owned by its order handler (issue sets the think
|
||||||
if (Core.AOS && IsFollowingMaster())
|
// 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;
|
if (Mobile.Warmode || Mobile.Combatant != null)
|
||||||
}
|
{
|
||||||
else if (Mobile.Warmode || Mobile.Combatant != null)
|
Mobile.SetCurrentSpeedToActive();
|
||||||
{
|
}
|
||||||
Mobile.SetCurrentSpeedToActive();
|
else
|
||||||
}
|
{
|
||||||
else
|
Mobile.SetCurrentSpeedToPassive();
|
||||||
{
|
}
|
||||||
Mobile.SetCurrentSpeedToPassive();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsumeMoveBudget();
|
ConsumeMoveBudget();
|
||||||
|
|
@ -541,8 +542,7 @@ public abstract partial class BaseAI
|
||||||
{
|
{
|
||||||
nextMove = NextMove;
|
nextMove = NextMove;
|
||||||
|
|
||||||
return (_moveIntentTarget != null || _moveIntentPoint != null) &&
|
return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0;
|
||||||
Core.TickCount - _moveIntentExpire < 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -599,6 +599,14 @@ public abstract partial class BaseAI
|
||||||
Mobile.ControlTarget == Mobile.ControlMaster &&
|
Mobile.ControlTarget == Mobile.ControlMaster &&
|
||||||
Mobile.Combatant == null;
|
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)
|
private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range)
|
||||||
{
|
{
|
||||||
var distance = (int)Mobile.GetDistanceToSqrt(target);
|
var distance = (int)Mobile.GetDistanceToSqrt(target);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ public abstract partial class BaseAI
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case OrderType.Come:
|
case OrderType.Come:
|
||||||
|
{
|
||||||
|
// RunUO parity: movement orders run the active think clock.
|
||||||
|
Mobile.SetCurrentSpeedToActive();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case OrderType.Drop:
|
case OrderType.Drop:
|
||||||
case OrderType.Friend:
|
case OrderType.Friend:
|
||||||
case OrderType.Unfriend:
|
case OrderType.Unfriend:
|
||||||
|
|
@ -136,6 +141,7 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
|
Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleTransferOrder()
|
private void HandleTransferOrder()
|
||||||
|
|
@ -149,6 +155,7 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
|
Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive
|
||||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +170,7 @@ public abstract partial class BaseAI
|
||||||
_commandIssuer?.RevealingAction();
|
_commandIssuer?.RevealingAction();
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = true;
|
Mobile.Warmode = true;
|
||||||
|
Mobile.SetCurrentSpeedToActive();
|
||||||
|
|
||||||
// Only a freshly issued command plays the flourish; resuming the persistent
|
// Only a freshly issued command plays the flourish; resuming the persistent
|
||||||
// order after an explicit attack must not replay it after every kill.
|
// 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.Warmode = true;
|
||||||
|
Mobile.SetCurrentSpeedToActive();
|
||||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +223,7 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
|
Mobile.SetCurrentSpeedToActive(); // RunUO parity: movement orders run active
|
||||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +239,7 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
|
Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive
|
||||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
|
// 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}.");
|
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)
|
if (currentDistance > 1)
|
||||||
{
|
{
|
||||||
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
||||||
|
|
@ -320,12 +327,23 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
if (distance > 3)
|
if (distance > 3)
|
||||||
{
|
{
|
||||||
// Through the approach primitive so guard-following registers a move
|
// RunUO parity: the AOS return sprints (bespoke 0.1 fuses to both
|
||||||
// intent (between-think move wakes) and paths around obstacles.
|
// 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);
|
WalkMobileRange(controlMaster, 1, true, 1, 3);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
|
||||||
WalkRandom(3, 1, 1);
|
WalkRandom(3, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -750,9 +750,9 @@ namespace Server.Mobiles
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolved seconds per step: a verbatim active/passive <see cref="CurrentSpeed"/>
|
/// 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.
|
/// maps to the matching movement value; a bespoke pace (e.g. the AOS 0.1 sprint
|
||||||
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>; a pet
|
/// pet orders write) stays fused to both clocks. A herded creature is always
|
||||||
/// executing a master's movement order paces on the think clock.
|
/// driven at <see cref="HerdingMoveSpeed"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public double CurrentMoveSpeed
|
public double CurrentMoveSpeed
|
||||||
|
|
@ -764,18 +764,6 @@ namespace Server.Mobiles
|
||||||
return HerdingMoveSpeed;
|
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
|
return _currentSpeed == _activeSpeed ? ActiveMoveSpeed
|
||||||
: _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
|
: _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
|
||||||
: _currentSpeed;
|
: _currentSpeed;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue