Compare commits

..

1 commit

Author SHA1 Message Date
Tald0r
38c74a968b
fix(regions): correct end Z coordinate assignment in InitRectangles (#2597)
The `ez` variable was incorrectly assigned `rect.End.X` instead of `rect.End.Z`, causing incorrect rectangle processing in region initialization.
2026-08-27 06:51:42 -07:00
12 changed files with 69 additions and 751 deletions

View file

@ -1,99 +0,0 @@
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

@ -1,140 +0,0 @@
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

@ -1,235 +0,0 @@
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,19 +118,18 @@ public abstract partial class BaseAI
if (TryMove(d))
{
// 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())
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
if (Core.AOS && IsFollowingMaster())
{
if (Mobile.Warmode || Mobile.Combatant != null)
{
Mobile.SetCurrentSpeedToActive();
}
else
{
Mobile.SetCurrentSpeedToPassive();
}
Mobile.CurrentSpeed = 0.1;
}
else if (Mobile.Warmode || Mobile.Combatant != null)
{
Mobile.SetCurrentSpeedToActive();
}
else
{
Mobile.SetCurrentSpeedToPassive();
}
ConsumeMoveBudget();
@ -542,7 +541,8 @@ 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,14 +599,6 @@ 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);
@ -657,12 +649,14 @@ public abstract partial class BaseAI
{
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
var shouldRun = run && iCurrDist > 5;
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
{
return true;
}
if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax))
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax))
{
return false;
}
@ -673,19 +667,18 @@ public abstract partial class BaseAI
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)
{
var shouldRun = run && iCurrDist > 5;
if (iCurrDist > iWantDistMax)
{
// Too far: approach via the centralized progress-based primitive.
return ApproachTarget(m, run, iWantDistMax);
return ApproachTarget(m, shouldRun, iWantDistMax);
}
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
if (DoMove(m.GetDirectionTo(Mobile, run), true))
if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true))
{
Path = null;
return true;

View file

@ -26,8 +26,6 @@ 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;
@ -42,34 +40,8 @@ public sealed class AITimer : Timer
public void Activate()
{
_nextThink = Core.TickCount;
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;
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
Start();
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
}
// A speed-up must not wait out a stale, longer think deadline.
@ -80,54 +52,12 @@ public sealed class AITimer : Timer
if (candidate - _nextThink < 0)
{
_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
}
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;
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
}
protected override void OnTick()
{
_inTick = true;
try
{
OnTickCore();
}
finally
{
_inTick = false;
}
}
private void OnTickCore()
{
if (ShouldStop())
{
@ -181,7 +111,6 @@ 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()

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)
{
AITimer.Activate();
AITimer.Start();
}
if (Action != ActionType.Wander)

View file

@ -26,8 +26,7 @@ public abstract partial class BaseAI
return;
}
// A fresh command wakes the AI immediately — never wait out the previous cadence.
AITimer.Prod();
Activate();
switch (Mobile.ControlOrder)
{
@ -37,11 +36,6 @@ 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:
@ -141,7 +135,6 @@ 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()
@ -155,7 +148,6 @@ 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;
}
@ -170,17 +162,9 @@ 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.
if (!_resolvingOrder)
{
Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you.
}
Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you.
_commandIssuer = null;
}
@ -207,7 +191,6 @@ public abstract partial class BaseAI
}
Mobile.Warmode = true;
Mobile.SetCurrentSpeedToActive();
Mobile.PlaySound(Mobile.GetAttackSound());
_commandIssuer = null;
}
@ -223,7 +206,6 @@ 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;
}
@ -239,7 +221,6 @@ 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,13 +128,6 @@ 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);
@ -298,15 +291,14 @@ public abstract partial class BaseAI
return true;
}
var combatant = FindGuardTarget();
FindCombatant();
if (combatant != null)
if (IsValidCombatant(Mobile.Combatant))
{
var combatant = Mobile.Combatant;
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.FocusMob = combatant;
Action = ActionType.Combat;
@ -317,33 +309,16 @@ public abstract partial class BaseAI
{
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
// 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 guardLocation = controlMaster.Location;
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
if (distance > 3)
{
// 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);
DoMove(Mobile.GetDirectionTo(guardLocation));
}
else
{
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
WalkRandom(3, 1, 1);
}
}
@ -384,86 +359,67 @@ public abstract partial class BaseAI
Mobile.ControlTarget = Mobile.ControlMaster;
ResumePersistentOrder();
// 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))
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
{
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();
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()
private void FindCombatant()
{
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))
{
if (aggr == best || aggr == Mobile || aggr == controlMaster ||
aggr.IsDeadBondedPet || !aggr.Alive ||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive)
{
continue;
}
var dist = aggr.GetDistanceToSqrt(anchor);
var isAttackingPet = aggr.Combatant == Mobile;
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
if (isAttackingPet || isAttackingMaster)
{
best = aggr;
bestDist = dist;
if (Mobile.InLOS(aggr))
{
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;
}
}
}
var aggressors = controlMaster?.Aggressors;
if (aggressors != null)
if (controlMaster?.Aggressors != null)
{
for (var i = 0; i < aggressors.Count; i++)
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
{
var aggressor = aggressors[i].Attacker;
var aggressor = controlMaster.Aggressors[i].Attacker;
if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive ||
aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception))
if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet)
{
continue;
}
var dist = aggressor.GetDistanceToSqrt(anchor);
if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
{
best = aggressor;
bestDist = dist;
Mobile.ControlTarget = aggressor;
Mobile.ControlOrder = OrderType.Attack;
Mobile.Combatant = aggressor;
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
Think();
return;
}
}
}
return best;
}
public virtual bool DoOrderRelease()

View file

@ -750,9 +750,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 (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"/>.
/// 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>
[CommandProperty(AccessLevel.GameMaster)]
public double CurrentMoveSpeed
@ -846,10 +845,6 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
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)]
public OrderType ControlOrder
{

View file

@ -113,7 +113,7 @@ public class BaseRegion : Region
m_RectBuffer2.RemoveAt(k);
var sz = rect.Start.Z;
var ez = rect.End.X;
var ez = rect.End.Z;
if (l1 < l2)
{

View file

@ -29,13 +29,6 @@ description: >
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
AND clears move overrides, `SetMoveSpeed()` sets move only -- see
`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

View file

@ -283,61 +283,6 @@ ClearMoveSpeed(); // back to inheriting the think clock
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).
### 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