feat: herding paces the movement clock, not the think clock

Herding is a movement scenario: the old CurrentSpeed getter hack forced
0.3 on the fused clock, which under the split misclassified - a herded
mob whose active think happens to be 0.3 walked at its (slow) move value
while any other mob got a bespoke 0.3. Now CurrentMoveSpeed drives a
herded creature at a fixed 0.3s per step - RunUO's forced pace, without
its TransformMoveDelay inflation to 0.6 - so herding is never penalized
by a slow creature. Thinking is untouched.

CheckHerding walks through MoveToPoint (cached boxed goal) instead of a
greedy per-think DoMove, so the movement clock actually governs pace
between think ticks - and herded creatures path around obstacles instead
of walking into them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-23 10:08:16 -07:00
parent 41ae2d8ffe
commit 5429465d64
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
3 changed files with 41 additions and 19 deletions

View file

@ -124,6 +124,21 @@ public class MoveSpeedTests : IDisposable
Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
} }
[Fact]
public void Herding_DrivesMoveClock_ThinkUntouched()
{
var bc = NewCreature(); // think 0.3/0.6, passive
bc.SetMoveSpeed(0.45, 1.05);
bc.TargetLocation = new Point2D(10, 10);
Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding
Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05
bc.TargetLocation = null;
Assert.Equal(1.05, bc.CurrentMoveSpeed);
}
[Fact] [Fact]
public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues() public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues()
{ {

View file

@ -40,6 +40,7 @@ public abstract partial class BaseAI
private Mobile _lkpTarget; private Mobile _lkpTarget;
private Point3D _lkpLocation; private Point3D _lkpLocation;
private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower
private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower
private long _lkpExpireTick; private long _lkpExpireTick;
private long _guardStopTick; private long _guardStopTick;
private long _investigateStopTick; private long _investigateStopTick;
@ -627,6 +628,7 @@ public abstract partial class BaseAI
if (target == null) if (target == null)
{ {
_herdGoal = null;
return false; return false;
} }
@ -634,7 +636,15 @@ public abstract partial class BaseAI
if (distance >= 1 && distance <= 15) if (distance >= 1 && distance <= 15)
{ {
DoMove(Mobile.GetDirectionTo(target)); // A cached boxed goal keeps the PathFollower persistent across ticks; walking
// through MoveToPoint paces herding on the movement clock and paths around
// obstacles.
if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y)
{
_herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
}
MoveToPoint(_herdGoal, false);
return true; return true;
} }
@ -644,6 +654,7 @@ public abstract partial class BaseAI
} }
Mobile.TargetLocation = null; Mobile.TargetLocation = null;
_herdGoal = null;
return false; return false;
} }

View file

@ -269,8 +269,8 @@ namespace Server.Mobiles
private double _activeMoveSpeed; private double _activeMoveSpeed;
private double _passiveMoveSpeed; private double _passiveMoveSpeed;
// Herding - Overrides the AI to force the mob to move to a specific location // Herding - forces the mob to walk to a specific location, paced by the movement
// Thinking: 0.3s, Movement: 0.6s. // clock at HerdingMoveSpeed. Thinking is unaffected.
private IPoint2D _targetLocation; private IPoint2D _targetLocation;
private int m_DamageMax = -1; private int m_DamageMax = -1;
@ -723,21 +723,21 @@ namespace Server.Mobiles
set => _passiveMoveSpeed = value > 0 ? value : 0; set => _passiveMoveSpeed = value > 0 ? value : 0;
} }
// Herded creatures walk at a fixed standard pace regardless of their own speed
// (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6).
private const double HerdingMoveSpeed = 0.3;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public IPoint2D TargetLocation public IPoint2D TargetLocation
{ {
get => _targetLocation; get => _targetLocation;
set set => _targetLocation = value;
{
_targetLocation = value;
AIObject?.OnCurrentSpeedChanged();
}
} }
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public double CurrentSpeed public double CurrentSpeed
{ {
get => _targetLocation != null ? 0.3 : _currentSpeed; get => _currentSpeed;
set set
{ {
if (Math.Abs(_currentSpeed - value) > 0.0001) if (Math.Abs(_currentSpeed - value) > 0.0001)
@ -751,25 +751,21 @@ 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 stays fused to both clocks.
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>.
/// </summary> /// </summary>
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public double CurrentMoveSpeed public double CurrentMoveSpeed
{ {
get get
{ {
var current = CurrentSpeed; if (_targetLocation != null)
if (current == _activeSpeed)
{ {
return ActiveMoveSpeed; return HerdingMoveSpeed;
} }
if (current == _passiveSpeed) return _currentSpeed == _activeSpeed ? ActiveMoveSpeed
{ : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
return PassiveMoveSpeed; : _currentSpeed;
}
return current;
} }
} }