Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Kamron Batman
28c5ea2f68
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>
2026-08-25 09:43:02 -07:00
Kamron Batman
c411a035e4
docs: document the OnThink excess-call contract and MonsterAbility pacing
OnThink is a scheduler pass, not an action: the AI timer fires it at least at
the think cadence but also on command prods and reschedules, and players
macro commands deliberately. Overrides must gate consequential work on their
own tick-count deadlines (or be idempotent); bare per-call random rolls are
cosmetics-only. MonsterAbility lives 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.
Order-breaking abilities must own their hold duration — think latency is not
a hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:31:34 -07:00
Kamron Batman
1a17146e64
docs: note the spam-safety invariant on AITimer.Prod
A prodded think grants reaction, not action: steps are gated by the NextMove
budget, swings/casts/abilities/detect-hidden by their own timers, and each
command yields at most one think with no compounding. Verified against RunUO,
which granted zero extra thinks (change-guarded CurrentSpeed -> random 0-1s
timer restart) but also no action-rate advantage, for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:12:21 -07:00
Kamron Batman
2d07b019af
docs: document why ControlOrder fires its handler on every assignment
Reviewed whether OnCurrentOrderChanged should be change-guarded: it must not
be. A reissued order is a command — "all attack" retargets through
ControlTarget with the same order value, "all follow" breaks off combat,
"all stay" re-anchors Home — and each depends on the handler running (and,
since #2594, on the AI being prodded awake). No code path writes the same
value on a hot path; all AI-internal writes are genuine transitions, so
same-value fires only occur at player-command rate. Handlers that need
change-detection already receive the previous order (ResolveStop uses it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:06:27 -07:00
Kamron Batman
5d00149dd8
fix: keep the Guard order through combat and sprint guard returns
A guarding pet's engagement path (FindCombatant) rewrote ControlOrder to
Attack, so guard silently converted to a plain kill order mid-fight (#2595):
the (guarding)/guarded OPL tags vanished, the pet stopped scanning for the
master's threats and never retargeted, recall/gate left it behind
(TeleportPets only takes Guard/Follow/Come), and every engage->kill->resume
cycle replayed the "is now guarding you" flourish. Return pacing also
depended on stale Warmode, leaving guard returns active or passive by combat
history.

- FindCombatant is now FindGuardTarget: a pure selector that prefers the
  aggressor closest to the master (RunUO guard parity) and keeps the current
  combatant unless a strictly closer one exists. DoOrderGuard engages through
  it without ever leaving the Guard order.
- HandleInvalidControlTarget resumes the persistent order first and chains
  the next aggressor into an explicit Attack only for non-guard fallbacks —
  an explicit "all attack" completes, returns to guarding, and the guard scan
  takes over. Resuming Guard no longer replays the sound/message flourish.
- The peaceful guard branch stands down (Warmode/Combatant/FocusMob cleared)
  so pacing is deterministic, and guard returns sprint at the follow pace
  (0.1s/step, AOS) on the move clock while the think cadence is untouched.
- WalkMobileRange honors the caller's run flag instead of a hardcoded
  distance-5 gate; follow/guard run animation now matches their actual pace.

Closes #2595

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:06:09 -07:00
Kamron Batman
1ce2efb1ed
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>
2026-08-24 12:26:42 -07:00
11 changed files with 752 additions and 70 deletions

View file

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

View 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);
}
}

View 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");
}
}

View file

@ -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);
@ -649,14 +657,12 @@ public abstract partial class BaseAI
{ {
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
var shouldRun = run && iCurrDist > 5;
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
{ {
return true; return true;
} }
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax))
{ {
return false; return false;
} }
@ -667,18 +673,19 @@ public abstract partial class BaseAI
return dist >= iWantDistMin && dist <= iWantDistMax; 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) private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
{ {
var shouldRun = run && iCurrDist > 5;
if (iCurrDist > iWantDistMax) if (iCurrDist > iWantDistMax)
{ {
// Too far: approach via the centralized progress-based primitive. // 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). // 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; Path = null;
return true; return true;

View file

@ -26,6 +26,8 @@ public sealed class AITimer : Timer
{ {
private readonly BaseAI _owner; private readonly BaseAI _owner;
private long _nextThink; 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 _detectHiddenMinDelay;
private int _detectHiddenMaxDelay; private int _detectHiddenMaxDelay;
@ -40,8 +42,34 @@ public sealed class AITimer : Timer
public void Activate() public void Activate()
{ {
_nextThink = Core.TickCount; _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(); Start();
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
} }
// A speed-up must not wait out a stale, longer think deadline. // 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) if (candidate - _nextThink < 0)
{ {
_nextThink = candidate; _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() protected override void OnTick()
{
_inTick = true;
try
{
OnTickCore();
}
finally
{
_inTick = false;
}
}
private void OnTickCore()
{ {
if (ShouldStop()) 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. // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn.
Interval = TimeSpan.FromMilliseconds(delay); Interval = TimeSpan.FromMilliseconds(delay);
_nextWake = now + (long)Interval.TotalMilliseconds;
} }
private bool ShouldStop() private bool ShouldStop()

View file

@ -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) 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) if (Action != ActionType.Wander)

View file

@ -26,7 +26,8 @@ public abstract partial class BaseAI
return; return;
} }
Activate(); // A fresh command wakes the AI immediately — never wait out the previous cadence.
AITimer.Prod();
switch (Mobile.ControlOrder) switch (Mobile.ControlOrder)
{ {
@ -36,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:
@ -135,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()
@ -148,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;
} }
@ -162,9 +170,17 @@ public abstract partial class BaseAI
_commandIssuer?.RevealingAction(); _commandIssuer?.RevealingAction();
Mobile.FocusMob = null; Mobile.FocusMob = null;
Mobile.Warmode = true; Mobile.Warmode = true;
Mobile.PlaySound(Mobile.GetAttackSound()); Mobile.SetCurrentSpeedToActive();
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you. // 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; _commandIssuer = null;
} }
@ -191,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;
} }
@ -206,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;
} }
@ -221,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.

View file

@ -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);
@ -291,14 +298,15 @@ public abstract partial class BaseAI
return true; return true;
} }
FindCombatant(); var combatant = FindGuardTarget();
if (IsValidCombatant(Mobile.Combatant)) if (combatant != null)
{ {
var combatant = Mobile.Combatant;
this.DebugSayFormatted($"Attacking target: {combatant.Name}"); 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.Combatant = combatant;
Mobile.FocusMob = combatant; Mobile.FocusMob = combatant;
Action = ActionType.Combat; Action = ActionType.Combat;
@ -309,16 +317,33 @@ public abstract partial class BaseAI
{ {
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); 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) 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 else
{ {
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
WalkRandom(3, 1, 1); WalkRandom(3, 1, 1);
} }
} }
@ -359,67 +384,86 @@ public abstract partial class BaseAI
Mobile.ControlTarget = Mobile.ControlMaster; Mobile.ControlTarget = Mobile.ControlMaster;
ResumePersistentOrder(); 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 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)) 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; continue;
} }
var isAttackingPet = aggr.Combatant == Mobile; var dist = aggr.GetDistanceToSqrt(anchor);
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
if (isAttackingPet || isAttackingMaster) if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
{ {
if (Mobile.InLOS(aggr)) best = aggr;
{ bestDist = dist;
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;
}
} }
} }
if (controlMaster?.Aggressors != null) var aggressors = controlMaster?.Aggressors;
{
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
{
var aggressor = controlMaster.Aggressors[i].Attacker;
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; 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; best = aggressor;
Mobile.ControlOrder = OrderType.Attack; bestDist = dist;
Mobile.Combatant = aggressor;
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
Think();
return;
} }
} }
} }
return best;
} }
public virtual bool DoOrderRelease() public virtual bool DoOrderRelease()

View file

@ -750,8 +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"/>. /// pet orders write) 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
@ -845,6 +846,10 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public Point3D ControlDest { get; set; } 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)] [CommandProperty(AccessLevel.GameMaster)]
public OrderType ControlOrder public OrderType ControlOrder
{ {

View file

@ -29,6 +29,13 @@ description: >
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
AND clears move overrides, `SetMoveSpeed()` sets move only -- see AND clears move overrides, `SetMoveSpeed()` sets move only -- see
`dev-docs/content-patterns.md` § Creature Speeds `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 ## New Item Template

View file

@ -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 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). 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 3045s — 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 ## New Spell