Merge branch 'myChanges' into sovereignrealm

This commit is contained in:
WarrentyExpired 2026-09-02 10:17:35 -04:00
commit 2f81086157
43 changed files with 2093 additions and 418 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
# Distribution Files
/Distribution/Data/Files
/Distribution/Logger
/Distribution/Logger.*
/Distribution/ModernUO

View file

@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using Server.Collections;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class DamageEntryTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
private static List<Mobile> Damagers(Mobile victim)
{
var result = new List<Mobile>();
foreach (var de in victim.DamageEntries)
{
result.Add(de.Damager);
}
return result;
}
[Fact]
public void FreshMobile_HasNoEntries()
{
var m = new TestMobile();
try
{
Assert.Equal(0, m.DamageEntries.Count);
Assert.Null(m.FindMostRecentDamageEntry(true));
Assert.Null(m.FindLeastRecentDamageEntry(true));
Assert.Null(m.FindMostTotalDamageEntry(true));
Assert.Null(m.FindLeastTotalDamageEntry(true));
Assert.Null(m.FindDamageEntryFor(m));
}
finally
{
m.Delete();
}
}
[Fact]
public void RegisterDamage_OrdersLeastRecentToMostRecent()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(20, b);
victim.RegisterDamage(5, a); // a becomes most recent again
Assert.Equal(2, victim.DamageEntries.Count);
Assert.Equal(new[] { b, a }, Damagers(victim));
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
Assert.Same(a, victim.FindMostRecentDamager(true));
Assert.Same(b, victim.FindLeastRecentDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FindRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, victim); // self is most recent
Assert.Same(victim, victim.FindMostRecentDamager(true));
Assert.Same(a, victim.FindMostRecentDamager(false));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindLeastRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
victim.RegisterDamage(10, a);
Assert.Same(victim, victim.FindLeastRecentDamager(true));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindTotal_PicksByDamage_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(30, b); // ties a; b is more recent
victim.RegisterDamage(1, c);
Assert.Same(b, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void FindLeastTotal_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(5, b);
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
Assert.Same(a, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void Prune_RemovesExpiredPrefix_KeepsOrder()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
victim.RegisterDamage(10, b); // a is now expired, b is live
Assert.Equal(new[] { b }, Damagers(victim));
Assert.Null(victim.FindDamageEntryFor(a));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void Prune_AllExpired_EmptiesList()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, b);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
Assert.Equal(0, victim.DamageEntries.Count);
Assert.Null(victim.FindMostRecentDamageEntry(true));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void ClearDamageEntries_UnlinksEveryNode()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
var ea = victim.RegisterDamage(10, a);
var eb = victim.RegisterDamage(10, b);
victim.ClearDamageEntries();
Assert.Equal(0, victim.DamageEntries.Count);
Assert.False(ea.OnLinkList);
Assert.False(eb.OnLinkList);
Assert.Null(ea.Next);
Assert.Null(ea.Previous);
Assert.Null(eb.Next);
Assert.Null(eb.Previous);
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FullHitPoints_ClearsEntries()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
victim.Hits = 10;
victim.RegisterDamage(10, a);
Assert.Equal(1, victim.DamageEntries.Count);
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
victim.Hits = victim.HitsMax;
Assert.Equal(0, victim.DamageEntries.Count);
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void RegisterDamage_AccumulatesResponsibleMaster()
{
var victim = new TestMobile();
var master = new TestMobile();
var pet = new PetMobile { Master = master };
try
{
victim.RegisterDamage(10, pet);
var entry = victim.RegisterDamage(5, pet);
Assert.Same(pet, entry.Damager);
Assert.Equal(15, entry.DamageGiven);
Assert.NotNull(entry.Responsible);
Assert.Single(entry.Responsible);
Assert.Same(master, entry.Responsible[0].Damager);
Assert.Equal(15, entry.Responsible[0].DamageGiven);
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
}
}
}

View file

@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
public class DamageEntry
public class DamageEntry : IValueLinkListNode<DamageEntry>
{
public DamageEntry(Mobile damager) => Damager = damager;
@ -57,6 +57,11 @@ public class DamageEntry
public List<DamageEntry> Responsible { get; set; }
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
// Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list.
public DamageEntry Next { get; set; }
public DamageEntry Previous { get; set; }
public bool OnLinkList { get; set; }
}
[Flags]
@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>();
NextSkillTime = Core.TickCount;
DamageEntries = new List<DamageEntry>();
}
// Sectors
@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public static VisibleDamageType VisibleDamageType { get; set; }
public List<DamageEntry> DamageEntries { get; private set; }
private ValueLinkList<DamageEntry> _damageEntries;
/// <summary>
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
/// while still unlinking the real nodes — it silently corrupts the list.
/// </summary>
public ref readonly ValueLinkList<DamageEntry> DamageEntries
{
get
{
PruneExpiredDamageEntries();
return ref _damageEntries;
}
}
[CommandProperty(AccessLevel.GameMaster)]
public Mobile LastKiller { get; set; }
@ -1627,7 +1647,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
public bool HasTrade => m_NetState?.Trades.Count > 0;
public bool HasTrade => m_NetState?.Trades?.Count > 0;
public bool NoMoveHS { get; set; }
@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors[i].CanReportMurder = false;
}
if (DamageEntries.Count > 0)
{
DamageEntries.Clear(); // reset damage entries on full HP
}
ClearDamageEntries(); // reset damage entries on full HP
}
else if (CanRegenHits)
{
@ -5745,24 +5762,54 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
}
}
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
private void PruneExpiredDamageEntries()
{
#if DEBUG
for (var node = _damageEntries._first; node != null; node = node.Next)
{
Debug.Assert(
node.Next == null || node.Next.LastDamage >= node.LastDamage,
"Damage entries must be ordered by LastDamage ascending."
);
}
#endif
var first = _damageEntries._first;
if (first?.HasExpired != true)
{
return;
}
var firstLive = first.Next;
while (firstLive?.HasExpired == true)
{
firstLive = firstLive.Next;
}
if (firstLive == null)
{
_damageEntries.RemoveAll();
}
else
{
_damageEntries.RemoveAllBefore(firstLive);
}
}
public void ClearDamageEntries() => _damageEntries.RemoveAll();
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
{
for (var i = DamageEntries.Count - 1; i >= 0; --i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (allowSelf || de.Damager != this)
if (allowSelf || de.Damager != this)
{
return de;
}
@ -5775,21 +5822,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
{
for (var i = 0; i < DamageEntries.Count; ++i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._first; de != null; de = de.Next)
{
if (i < 0)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
--i;
}
else if (allowSelf || de.Damager != this)
if (allowSelf || de.Damager != this)
{
return de;
}
@ -5800,24 +5837,17 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
// Walks most recent first with a strict comparison so the most recent entry wins ties,
// matching the previous reverse-indexed loop.
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
{
PruneExpiredDamageEntries();
DamageEntry mostTotal = null;
for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
{
mostTotal = de;
}
@ -5830,46 +5860,28 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
{
DamageEntry mostTotal = null;
PruneExpiredDamageEntries();
for (var i = DamageEntries.Count - 1; i >= 0; --i)
DamageEntry leastTotal = null;
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
{
mostTotal = de;
leastTotal = de;
}
}
return mostTotal;
return leastTotal;
}
public DamageEntry FindDamageEntryFor(Mobile m)
{
for (var i = DamageEntries.Count - 1; i >= 0; --i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (de.Damager == m)
if (de.Damager == m)
{
return de;
}
@ -5887,8 +5899,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
de.DamageGiven += amount;
de.LastDamage = Core.Now;
DamageEntries.Remove(de);
DamageEntries.Add(de);
// Move to the tail so the list stays in LastDamage order.
if (de.OnLinkList)
{
_damageEntries.Remove(de);
}
_damageEntries.AddLast(de);
var master = from.GetDamageMaster(this);
@ -6478,9 +6495,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_DexLock = (StatLockType)reader.ReadByte();
m_IntLock = (StatLockType)reader.ReadByte();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
if (version < 32)
{
if (reader.ReadBool())
@ -7813,13 +7827,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_FollowersMax = 5;
Skills = new Skills(this);
Items = new List<Item>();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
Map = Map.Internal;
AutoPageNotify = true;
Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>();
DamageEntries = new List<DamageEntry>();
NextSkillTime = Core.TickCount;
}

View file

@ -50,9 +50,6 @@ public static class MovementThrottle
private const int ClientMaxUnackedMovements = 5;
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
// Debug logging - enable for testing speed hack detection
private static bool _debugLogging = false;
// Track NetStates with queued movements for efficient processing
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
@ -83,15 +80,9 @@ public static class MovementThrottle
public static void Configure()
{
_maxCredit = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.maxCredit",
_maxCredit
);
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.hardQueueLimit",
_hardQueueLimit
);
_maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit);
_maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus);
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit);
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.movementHistorySize",
@ -103,6 +94,13 @@ public static class MovementThrottle
_minSamplesForRate
);
_maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap);
_speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.speedHackNotificationCooldown",
_speedHackNotificationCooldown
);
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.suspiciousRateThreshold",
_suspiciousRateThreshold
@ -112,11 +110,6 @@ public static class MovementThrottle
"movementThrottle.definiteRateThreshold",
_definiteRateThreshold
);
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.debugLogging",
_debugLogging
);
}
/// <summary>
@ -191,15 +184,16 @@ public static class MovementThrottle
// Credit can go negative up to -dynamicCredit (debt limit)
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
{
var prevCredit = ns._movementCredit;
// Use credit to cover early arrival
ns._movementCredit -= earlyAmount;
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
var prevCredit = ns._movementCredit + earlyAmount;
logger.Debug(
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
);
}
@ -208,11 +202,11 @@ public static class MovementThrottle
return;
}
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit
mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit
);
}
@ -227,11 +221,11 @@ public static class MovementThrottle
var prevCredit = ns._movementCredit;
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit)
if (ns._movementLogging && ns._movementCredit != prevCredit)
{
logger.Debug(
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit
mobile, delta, prevCredit, ns._movementCredit, dynamicCredit
);
}
}
@ -247,12 +241,9 @@ public static class MovementThrottle
{
if (!mobile.Move(dir))
{
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset",
mobile.RawName, dir, seq
);
logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq);
}
// Movement failed (blocked, paralyzed, frozen, etc.)
@ -260,11 +251,11 @@ public static class MovementThrottle
return;
}
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount
mobile, dir, seq, ns._nextMovementTime - Core.TickCount
);
}
@ -304,11 +295,11 @@ public static class MovementThrottle
ns._hasQueuedMovements = true;
_netStatesWithQueuedMovements.Add(ns);
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count
ns.Mobile, dir, seq, ns._movementQueue.Count
);
}
}
@ -320,7 +311,6 @@ public static class MovementThrottle
{
ns.SendMovementRej(seq, mobile);
ns.ResetMovementState();
_netStatesWithQueuedMovements.Remove(ns);
}
/// <summary>
@ -333,20 +323,18 @@ public static class MovementThrottle
return;
}
// Process each NetState with queued movements
// Use a snapshot to avoid modification during iteration
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
for (var i = 0; i < toProcess.Count; i++)
foreach (var ns in _netStatesWithQueuedMovements)
{
var ns = toProcess[i];
if (!ns.Running)
if (ns.Running)
{
_netStatesWithQueuedMovements.Remove(ns);
continue;
ProcessMovementQueue(ns);
if (ns._hasQueuedMovements)
{
continue;
}
}
ProcessMovementQueue(ns);
_netStatesWithQueuedMovements.Remove(ns);
}
}
@ -356,6 +344,7 @@ public static class MovementThrottle
public static void ProcessMovementQueue(NetState ns)
{
var mobile = ns.Mobile;
if (mobile?.Deleted != false)
{
ClearQueue(ns);
@ -374,7 +363,7 @@ public static class MovementThrottle
while (ns._movementQueue?.Count > 0)
{
// Check if it's time to execute
if (now < ns._nextMovementTime)
if (now - ns._nextMovementTime < 0)
{
// Not yet - leave remaining items in queue for next Slice
break;
@ -394,11 +383,11 @@ public static class MovementThrottle
// Execute the move
if (!mobile.Move(movement.Direction))
{
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
mobile.RawName, movement.Direction, remaining
mobile, movement.Direction, remaining
);
}
@ -407,12 +396,12 @@ public static class MovementThrottle
return;
}
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
var waited = now - ns._nextMovementTime;
logger.Debug(
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0
mobile, movement.Direction, remaining, waited >= 0 ? waited : 0
);
}
@ -430,10 +419,6 @@ public static class MovementThrottle
// Update tracking
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
if (!ns._hasQueuedMovements)
{
_netStatesWithQueuedMovements.Remove(ns);
}
}
/// <summary>
@ -469,7 +454,6 @@ public static class MovementThrottle
{
ns._movementQueue?.Clear();
ns._hasQueuedMovements = false;
_netStatesWithQueuedMovements.Remove(ns);
}
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
@ -484,7 +468,7 @@ public static class MovementThrottle
logger.Information(
"Movement queue overflow: {Character} ({Account}) | " +
"Queue reached hard limit: {Limit} | IP: {IP}",
mobile?.RawName ?? "Unknown",
mobile,
ns.Account?.Username ?? "Unknown",
_hardQueueLimit,
ns.Address
@ -516,7 +500,7 @@ public static class MovementThrottle
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
{
// Calculate interval since last movement
var interval = ns._lastMovementRecordTime > 0
var interval = ns._hasMovementRecord
? (int)(now - ns._lastMovementRecordTime)
: -1; // -1 indicates first movement (no previous time)
@ -525,6 +509,7 @@ public static class MovementThrottle
if (interval <= 0 || interval > _maxChainGap)
{
ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Use RTT to distinguish "stopped moving" vs "lagged"
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
@ -544,19 +529,19 @@ public static class MovementThrottle
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
ns._lastGapDuration = interval;
if (_debugLogging && mobile?.RawName != null)
if (ns._movementLogging)
{
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
logger.Debug(
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
"RTT={RTT}ms stable={Stable} → {Action})",
mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
);
}
}
else if (_debugLogging && mobile?.RawName != null)
else if (ns._movementLogging)
{
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName);
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile);
}
return;
@ -572,12 +557,9 @@ public static class MovementThrottle
// the next real move's interval artificially short, inflating rate.
if (cost == 0)
{
if (_debugLogging && mobile?.RawName != null)
if (ns._movementLogging)
{
logger.Debug(
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
mobile.RawName
);
logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile);
}
return;
}
@ -613,15 +595,16 @@ public static class MovementThrottle
}
ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Debug logging
if (_debugLogging && mobile?.RawName != null)
if (ns._movementLogging)
{
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
logger.Debug(
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
mobile.RawName, interval, cost, record.QueueDepth,
mobile, interval, cost, record.QueueDepth,
flags, historyCount, _movementHistorySize, ns.AverageRtt
);
}
@ -814,7 +797,7 @@ public static class MovementThrottle
var averageRtt = ns.AverageRtt;
// Detailed rate breakdown for debugging
if (_debugLogging)
if (ns._movementLogging)
{
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
rate, sampleCount, averageRtt);
@ -977,19 +960,19 @@ public static class MovementThrottle
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
// Debug logging
if (_debugLogging && ns.Mobile?.RawName != null)
if (ns._movementLogging)
{
var (burstSize, _) = DetectRecentBurst(ns);
var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle";
var probeStatus = ns._rttProbePending ? "pending" : "idle";
var queueDepth = ns._movementQueue?.Count ?? 0;
logger.Debug(
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
);
logger.Debug(
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
);
}
@ -1025,11 +1008,11 @@ public static class MovementThrottle
if (shouldNotify)
{
if (_debugLogging)
if (ns._movementLogging)
{
logger.Debug(
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
urgency, ns.Mobile?.RawName, rate, verdict, confidence
urgency, ns.Mobile, rate, verdict, confidence
);
}
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
@ -1054,11 +1037,12 @@ public static class MovementThrottle
var now = Core.TickCount;
// Rate-limit notifications per player
if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
{
return;
}
ns._speedHackNotified = true;
ns._lastSpeedHackNotification = now;
var mobile = ns.Mobile;
@ -1070,7 +1054,7 @@ public static class MovementThrottle
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
urgency,
mobile?.RawName ?? "Unknown",
mobile,
ns.Account?.Username ?? "Unknown",
rate,
sampleCount,
@ -1138,7 +1122,7 @@ public static class MovementThrottle
Verdict = verdict,
Confidence = confidence,
AverageRtt = ns.AverageRtt,
LastRtt = ns._lastRtt,
LastRtt = ns.LastRtt,
RttVariance = ns._rttVariance,
StableConnection = ns.HasStableConnection,
RttSampleCount = ns._rttSampleCount,

View file

@ -15,7 +15,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Server.Logging;
@ -70,23 +69,24 @@ public partial class NetState
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
internal long _movementCredit; // Credit buffer for timing jitter
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
internal long _lastQueueDepthCheck; // Throttle depth check frequency
internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency
internal bool _hasQueuedMovements; // Fast check for Slice()
// Movement history for rate-based speed hack detection (lazy initialized)
internal MovementRecord[] _movementHistory; // Circular buffer
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
internal bool _movementHistoryFull; // True once buffer has wrapped
internal long _lastMovementRecordTime; // For calculating intervals
internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord)
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
// Detection state
internal int _consecutiveHighRateSeconds; // Sustained detection counter
internal long _lastSpeedHackNotification; // Rate-limit notifications
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
internal bool _speedHackNotified; // False until the first notification is sent
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
// Movement packet rate tracking (for speed hack detection)
internal long _movementWindowStart; // Start of current 1-second window
internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window
internal int _movementsInWindow; // Count in current window
internal int _peakMovementRate; // Highest rate seen (packets/sec)
@ -100,10 +100,9 @@ public partial class NetState
_nextMovementTime = Core.TickCount;
_movementCredit = 0;
_hasQueuedMovements = false;
_sustainedQueueDepth = 0;
// Reset movement history - next movement starts a new chain
_lastMovementRecordTime = 0;
_hasMovementRecord = false;
_movementHistoryIndex = 0;
_movementHistoryFull = false;
@ -113,7 +112,7 @@ public partial class NetState
_rttProbeInterval = RttProbeIntervalNormal;
// Reset packet rate window
_movementWindowStart = 0;
_movementWindowStart = Core.TickCount;
_movementsInWindow = 0;
}
@ -165,17 +164,19 @@ public partial class NetState
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
// RTT state
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
internal long _lastRtt; // Most recent RTT measurement
internal bool _rttProbePending; // True while waiting for a probe response
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
internal long[] _rttHistory; // Rolling history (lazy init)
internal int _rttHistoryIndex; // Current position in history
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
internal long _rttVariance; // Calculated variance for stability
internal long _nextRttProbe; // When to send next probe
internal long _nextRttProbe = Core.TickCount; // When to send next probe
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
private long _rttProbeTimestampHiRes;
/// <summary>
/// Gets the most recent RTT measurement, or 0 if none has been recorded.
/// </summary>
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
/// <summary>
/// Sets the RTT probe interval based on suspicion level.
@ -206,23 +207,22 @@ public partial class NetState
var now = Core.TickCount;
// Don't send if we're still waiting for a response
if (_rttProbeTime > 0)
if (_rttProbePending)
{
// Timeout after 10 seconds - connection is probably dead or very laggy
if (now - _rttProbeTime > 10000)
{
_rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
_rttProbePending = false;
}
return;
}
// First probe: send immediately when player starts moving
// Subsequent probes: send when interval has passed
if (_nextRttProbe == 0 || now >= _nextRttProbe)
if (now - _nextRttProbe >= 0)
{
_rttProbePending = true;
_rttProbeTime = now;
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
if (_movementLogging)
@ -242,10 +242,9 @@ public partial class NetState
/// </summary>
public void RecordRttMeasurement()
{
var nowHiRes = Stopwatch.GetTimestamp();
var now = Core.TickCount;
if (_rttProbeTime <= 0)
if (!_rttProbePending)
{
// Not expecting a response (client-initiated version send) - ignore silently
return;
@ -253,19 +252,15 @@ public partial class NetState
var rtt = now - _rttProbeTime;
// High-resolution RTT in microseconds
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
"[RTT-Response] {Account}: {Rtt}ms",
Account?.Username ?? _toString, rtt
);
}
_rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
_rttProbePending = false;
// Sanity check - RTT should be positive and reasonable
if (rtt is <= 0 or > 10000)
@ -285,7 +280,6 @@ public partial class NetState
// Update history
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
_lastRtt = rtt;
// Track sample count (saturates at buffer size)
if (_rttSampleCount < RttHistorySize)

View file

@ -44,7 +44,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
private static readonly Queue<NetState> _connectingQueue = new(2048);
private static readonly HashSet<NetState> _instances = new(2048);
public static IReadOnlySet<NetState> Instances => _instances;
public static HashSet<NetState> Instances => _instances;
private readonly string _toString;
private ClientVersion _version;
@ -109,9 +109,6 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
Address = address;
Seeded = false;
HuePickers = [];
Menus = [];
Trades = [];
NextActivityCheck = Core.TickCount + 30000;
ConnectedOn = Core.Now;
_toString = address?.ToString() ?? "(error)";
@ -166,7 +163,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public bool BlockAllPackets { get; set; }
public List<SecureTrade> Trades { get; }
public List<SecureTrade> Trades { get; private set; }
public bool Seeded { get; set; }
@ -260,8 +257,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void ValidateAllTrades()
{
if (Trades == null)
{
return;
}
for (var i = Trades.Count - 1; i >= 0; --i)
{
if (Trades == null)
{
break;
}
if (i >= Trades.Count)
{
continue;
@ -280,8 +287,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void CancelAllTrades()
{
if (Trades == null)
{
return;
}
for (var i = Trades.Count - 1; i >= 0; --i)
{
if (Trades != null)
{
break;
}
if (i < Trades.Count)
{
Trades[i].Cancel();
@ -291,11 +308,21 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void RemoveTrade(SecureTrade trade)
{
Trades.Remove(trade);
Trades?.Remove(trade);
if (Trades?.Count == 0)
{
Trades = null;
}
}
public SecureTrade FindTrade(Mobile m)
{
if (Trades == null)
{
return null;
}
for (var i = 0; i < Trades.Count; ++i)
{
var trade = Trades[i];
@ -311,6 +338,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public SecureTradeContainer FindTradeContainer(Mobile m)
{
if (Trades == null)
{
return null;
}
for (var i = 0; i < Trades.Count; ++i)
{
var trade = Trades[i];
@ -336,7 +368,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
{
var newTrade = new SecureTrade(Mobile, state.Mobile);
Trades ??= [];
Trades.Add(newTrade);
state.Trades ??= [];
state.Trades.Add(newTrade);
return newTrade.From.Container;
@ -1176,8 +1212,16 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
var a = Account;
Menus.Clear();
HuePickers.Clear();
Menus?.Clear();
Menus = null;
HuePickers?.Clear();
HuePickers = null;
// Just in case, but should already be nulled when Mobile.NetState is set to null and CancelAllTrades is called.
Trades?.Clear();
Trades = null;
Account = null;
ServerInfo = null;
CityInfo = null;

View file

@ -103,6 +103,7 @@ internal static class TestServerInitializer
// Registers the Accounts entity persistence; without it no test can construct an Account.
Server.Accounting.Accounts.Configure();
RaceDefinitions.Configure();
Server.Movement.Movement.Configure();
MovementImpl.Configure();
PathFollower.Configure();
World.Load();

View file

@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the
// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero =
// prodded scan); an illegal deadline self-heals.
[Collection("Sequential Pathfinding Tests")]
public class AcquisitionTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private sealed class WildStub : BaseCreature
{
public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
private sealed class TargetStub : Mobile
{
public TargetStub() => Body = 0x190;
}
private WildStub Spawn(Map map, Point3D loc)
{
var bc = new WildStub();
bc.MoveToWorld(loc, map);
bc.AIObject.AITimer?.Stop();
_created.Add(bc);
return bc;
}
[Fact]
public void EmptyScan_HonorsReacquireDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount;
Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000);
}
[Fact]
public void WedgedGate_SelfHeals()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
var target = new TargetStub();
target.DefaultMobileInit();
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
_created.Add(target);
// Illegal deadline (beyond ReacquireDelay): must read as open, not block forever.
bc.NextReacquireTime = Core.TickCount + 60000;
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.Equal(target, bc.FocusMob);
}
[Theory]
[InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline
[InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored
[InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only
[InlineData(false, 20, false)] // outside approach range (10) is ignored
public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices)
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount + 8000;
Mobile mover;
if (wildMover)
{
mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z));
}
else
{
mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map);
_created.Add(mover);
}
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
var remaining = bc.NextReacquireTime - Core.TickCount;
if (notices)
{
// Clamped to the approach delay (2s), never opened outright.
Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds);
}
else
{
Assert.True(remaining > 5000);
}
}
private sealed class InstantStub : BaseCreature
{
public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
[Fact]
public void ZeroApproachDelay_OpensGateImmediately()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = new InstantStub();
bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
bc.AIObject.AITimer?.Stop();
_created.Add(bc);
bc.NextReacquireTime = Core.TickCount + 8000;
var mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
_created.Add(mover);
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
// Zero = the gate opens and the AI is prodded to think now; no direct engage.
Assert.True(Core.TickCount - bc.NextReacquireTime >= 0);
Assert.Null(bc.Combatant);
Assert.True(bc.AIObject.AITimer.Running);
}
[Fact]
public void RepeatedMovement_DoesNotShortenBelowApproachDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount + 8000;
var mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
_created.Add(mover);
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
var afterFirst = bc.NextReacquireTime;
bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z));
Assert.Equal(afterFirst, bc.NextReacquireTime);
}
[Fact]
public void SuccessfulAcquire_HoldsFullDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
var target = new TargetStub();
target.DefaultMobileInit();
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
_created.Add(target);
bc.NextReacquireTime = Core.TickCount;
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.Equal(target, bc.FocusMob);
Assert.True(bc.NextReacquireTime - Core.TickCount > 5000);
}
}

View file

@ -40,7 +40,7 @@ public class ApproachTargetTests
for (var i = 0; i < maxTicks; i++)
{
ai.NextMove = 0;
ai.WalkMobileRange(target, 1, false, 1, 2);
ai.WalkMobileRange(target, 1, 1, 2);
if (bc.InRange(target, arriveDist))
{
return true;
@ -123,7 +123,7 @@ public class ApproachTargetTests
for (var i = 0; i < 200; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, false, 1);
ai.MoveTo(target, 1);
if (bc.InRange(target, 1))
{
arrived = true;
@ -154,7 +154,7 @@ public class ApproachTargetTests
for (var i = 0; i < 60; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, true, 1);
ai.MoveTo(target, 1);
// Target walks west every other tick for its first several steps, then stops,
// so a same-speed chaser eventually closes the gap.
@ -214,7 +214,7 @@ public class ApproachTargetTests
for (var i = 0; i < 120; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, false, 1);
ai.MoveTo(target, 1);
}
// After giving up, the creature must idle (not oscillate) while the goal is still.
@ -223,7 +223,7 @@ public class ApproachTargetTests
for (var i = 0; i < 20; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, false, 1);
ai.MoveTo(target, 1);
if (bc.Location != idleStart)
{
stayedIdle = false;

View file

@ -0,0 +1,96 @@
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Guard-following may pathfind, so this shares the pathfinding collection.
[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");
// Without a move intent, guard-following only steps on the think grid.
Assert.True(hasIntent, "guard-following must register a move intent");
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
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 active.
Assert.Equal(0.2, currentSpeed);
}
finally
{
Core.Expansion = previous;
}
}
}

View file

@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// A guarding pet fights without leaving the Guard order, retargets toward the master's
// closest aggressor, and stands down when nothing threatens. Scene: the open
// (1495..1500, 1600) Trammel segment; 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);
// Setup guard: the scene must stay LOS-clear and the combatant 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,222 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pet order handlers own the speed clocks; 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();
}
// Movement orders run active, resting orders run passive; the move clock follows.
[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); // 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);
}
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
[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
pet.AIObject.Obey();
Assert.Equal(0.1, pet.CurrentSpeed);
Assert.Equal(0.1, pet.CurrentMoveSpeed);
}
// At the master's side a guarding pet stays active: no stale-warmode passive, no sprint.
[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);
}
// 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);
}
// Herding overrides order 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 time in 8ms lockstep so the wheel 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 spawn stagger; returns right after a think with the next 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;
RunFor(200); // mid-wait, next think ~200ms out
Assert.Equal(thinksBefore, pet.Thinks);
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;
RunFor(200); // mid-wait, next think ~200ms out
Assert.Equal(thinksBefore, pet.Thinks);
pet.CurrentSpeed = 0.1;
RunFor(120);
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
}
}

View file

@ -0,0 +1,162 @@
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// The Running bit is derived from the step pace: a step shorter than the client's walk
// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run.
[Collection("Sequential Pathfinding Tests")]
public class RunFlagTests : System.IDisposable
{
private readonly List<Mobile> _created = new();
private PetTestStub Spawn(double activeMove)
{
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
pet.AIObject.AITimer?.Stop();
pet.SetMoveSpeed(activeMove, activeMove * 3);
pet.SetCurrentSpeedToActive();
pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise
_created.Add(pet);
return pet;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
[Theory]
[InlineData(0.3, true)]
[InlineData(0.125, true)]
[InlineData(0.4, false)]
[InlineData(0.45, false)]
[InlineData(1.05, false)]
public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected)
{
var pet = Spawn(activeMove);
Assert.Equal(activeMove, pet.CurrentMoveSpeed);
Assert.Equal(expected, pet.AIObject.ShouldRun());
}
[Theory]
[InlineData(0.3, false)]
[InlineData(0.15, true)]
public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected)
{
var pet = Spawn(activeMove);
pet.Flying = true;
Assert.Equal(expected, pet.AIObject.ShouldRun());
}
[Fact]
public void BadlyHurt_SlowsBelowWalk_DropsToWalk()
{
var pet = Spawn(0.35);
Assert.True(pet.AIObject.ShouldRun());
// The hurt inflation is on the observed step pace, so the flag follows it.
pet.SetHits(100);
pet.Hits = 5;
pet.SetStam(100);
pet.Stam = 5;
Assert.False(pet.AIObject.ShouldRun());
}
[Theory]
[InlineData(0.3, true)]
[InlineData(0.45, false)]
public void DoMove_StampsRunningBit(double activeMove, bool expected)
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(activeMove);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
var ai = pet.AIObject;
ai.NextMove = 0;
var start = pet.Location;
Assert.True(ai.DoMove(Direction.West));
Assert.NotEqual(start, pet.Location);
Assert.Equal(expected, (pet.Direction & Direction.Running) != 0);
}
// An isolated step (after standing at least a walk interval) renders alone and darts
// if run-flagged, so it walks; continuing cadences and true sprinters keep the flag.
[Fact]
public void IsolatedStep_DropsToWalk()
{
var pet = Spawn(0.3);
pet.LastMoveTime = Core.TickCount - 1000;
Assert.False(pet.AIObject.ShouldRun());
}
[Fact]
public void IsolatedStep_SprinterStillRuns()
{
var pet = Spawn(0.125);
pet.LastMoveTime = Core.TickCount - 1000;
Assert.True(pet.AIObject.ShouldRun());
}
[Fact]
public void StallDoesNotBankCatchUpSteps()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(0.3);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
var ai = pet.AIObject;
ai.NextMove = Core.TickCount - 1000;
Assert.True(ai.DoMove(Direction.West));
// A stall must restart the cadence at full pace: banked catch-up steps
// release as a burst the client renders as a sprint/teleport.
Assert.False(ai.CanMoveNow(out _));
Assert.True(ai.NextMove - Core.TickCount > 250);
}
[Fact]
public void LateStepDoesNotEarnAQuickerFollowUp()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(0.3);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
pet.Warmode = true; // keep the active move clock through the step
var ai = pet.AIObject;
// The step lands 200ms past the budget — under one period, the reactive
// mirroring case (think grid vs budget deadline misalignment).
ai.NextMove = Core.TickCount - 200;
Assert.True(ai.DoMove(Direction.West));
// The debt must not be repaid: a sub-period catch-up step follows ~100ms
// behind and renders as a dart pair beside the player.
Assert.True(ai.NextMove - Core.TickCount > 250);
}
}

View file

@ -0,0 +1,146 @@
using System.Collections.Generic;
using Server.Mobiles;
using Xunit;
namespace Server.Tests;
/// <summary>
/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the
/// returned stores are sorted by damage descending, the first (least recent) damager takes the
/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited
/// to its damage master rather than to the pet.
/// </summary>
[Collection("Sequential UOContent Tests")]
public class LootingRightsTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
// GetLootingRights only ever credits mobiles flagged as players.
private static TestMobile NewPlayer() => new() { Player = true };
private static DamageStore FindStore(List<DamageStore> rights, Mobile m)
{
for (var i = 0; i < rights.Count; i++)
{
if (rights[i].m_Mobile == m)
{
return rights[i];
}
}
return null;
}
[Fact]
public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager"
// hitsMax < 200 puts the bar at topDamage / 2.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
Assert.Equal(2, rights.Count);
// Sorted by damage descending.
Assert.True(rights[0].m_Damage >= rights[1].m_Damage);
Assert.Same(first, rights[0].m_Mobile);
Assert.Same(second, rights[1].m_Mobile);
// The first damager - the least recent entry - gets the 1.25x bonus; nobody else does.
Assert.Equal(125, rights[0].m_Damage);
Assert.Equal(40, rights[1].m_Damage);
// topDamage 125 / 2 = 62, so 40 is below the bar.
Assert.True(rights[0].m_HasRight);
Assert.False(rights[1].m_HasRight);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void HitsMaxBand_MovesTheRightsThreshold()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second);
// hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200);
Assert.Equal(2, rights.Count);
Assert.True(rights[0].m_HasRight);
Assert.True(rights[1].m_HasRight);
Assert.Same(second, rights[1].m_Mobile);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void PetDamage_CreditsTheMaster_NotThePet()
{
var victim = new TestMobile();
var master = NewPlayer();
var pet = new PetMobile { Master = master };
var wild = new TestMobile(); // no damage master, and not a player
try
{
victim.RegisterDamage(50, pet);
victim.RegisterDamage(20, wild);
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
// The master is credited through the entry's Responsible sub-entry, and is the only one.
Assert.Single(rights);
var masterStore = FindStore(rights, master);
Assert.NotNull(masterStore);
Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus
Assert.True(masterStore.m_HasRight);
// The pet's own damage was fully handed to the master, so it earns no store.
Assert.Null(FindStore(rights, pet));
// A non-player damager earns nothing even when its damage was never reassigned.
Assert.Null(FindStore(rights, wild));
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
wild.Delete();
}
}
}

View file

@ -18,6 +18,7 @@ using System.Net;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.Serialization;
using Server.Collections;
using Server.Engines.Virtues;
using Server.Gumps;
using Server.Items;
@ -1181,11 +1182,6 @@ public partial class ChampionSpawn : Item
foreach (var de in m.DamageEntries)
{
if (de.HasExpired)
{
continue;
}
var damager = de.Damager;
var master = damager.GetDamageMaster(m);

View file

@ -359,14 +359,14 @@ namespace Server.Factions
{
if (m_Mobile.InRange( m, 1 ))
RunFrom( m );
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 ))
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1))
OnFailedMove();
}
else
{*/
if (!Mobile.InRange(m, Mobile.RangeFight))
{
if (!MoveTo(m, true, 1))
if (!MoveTo(m, 1))
{
OnFailedMove();
}

View file

@ -222,7 +222,7 @@ namespace Server.Engines.Harvest
}
else
{
item.Delete();
bonusItem?.Delete();
}
}

View file

@ -83,7 +83,7 @@ public class PathFollower
public static bool Check(Point3D loc, Point3D goal, int range) =>
Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16);
public bool Follow(bool run, int range)
public bool Follow(int range)
{
var goal = GetGoalLocation();
Direction d;
@ -97,13 +97,13 @@ public class PathFollower
if (!(Enabled && m_Path.Success))
{
d = m_From.GetDirectionTo(goal, run);
d = m_From.GetDirectionTo(goal);
m_From.SetDirection(d);
return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range);
}
d = m_From.GetDirectionTo(m_Next, run);
d = m_From.GetDirectionTo(m_Next);
m_From.SetDirection(d);
var res = Move(d);

View file

@ -38,7 +38,7 @@ public class AnimalAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
{
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
{

View file

@ -17,7 +17,7 @@ public class ArcherAI : BaseAI
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
{
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack");
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
Mobile.Combatant = Mobile.FocusMob;
Action = ActionType.Combat;
@ -43,7 +43,7 @@ public class ArcherAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange))
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange))
{
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");

View file

@ -63,7 +63,7 @@ public abstract partial class BaseAI
return crowding;
}
public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range)
public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range)
{
if (Core.TickCount - _lastGroupUpdateTime > 1000)
{
@ -79,7 +79,7 @@ public abstract partial class BaseAI
if (optimalPosition == Point3D.Zero)
{
return ai.MoveToWithCollisionAvoidance(target, run, range);
return ai.MoveToWithCollisionAvoidance(target, range);
}
_reservedPositions[mobile] = optimalPosition;
@ -99,7 +99,7 @@ public abstract partial class BaseAI
}
// A blocked or wall-slid step is not progress — route around the obstacle.
return ai.ApproachTarget(target, run, range);
return ai.ApproachTarget(target, range);
}
finally
{

View file

@ -18,6 +18,7 @@ using System.Runtime.CompilerServices;
using Server.Collections;
using Server.Items;
using MoveImpl = Server.Movement.MovementImpl;
using Moves = Server.Movement.Movement;
namespace Server.Mobiles;
@ -43,7 +44,6 @@ public abstract partial class BaseAI
// live, the AITimer wakes at NextMove between think ticks to advance the step.
private Mobile _moveIntentTarget;
private IPoint3D _moveIntentPoint;
private bool _moveIntentRun;
private int _moveIntentRange;
private long _moveIntentExpire;
@ -74,23 +74,42 @@ public abstract partial class BaseAI
return Core.TickCount - NextMove >= 0;
}
// Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly
// regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step.
private void ConsumeMoveBudget()
// Seconds per step as the client observes it: the move clock plus the hurt inflation.
private double EffectiveStepDelay()
{
var stepDelay = Mobile.CurrentMoveSpeed;
if (!(Core.AOS && IsFollowingMaster()))
return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay);
}
// The Running bit only selects the client's per-step interpolation (walk 400ms / run
// 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the
// client falls behind and snaps — but an isolated step (after standing at least a walk
// interval) renders alone and darts if run-flagged, so it goes out as a walk. A true
// sprinter always runs: a walk-rendered first step would flood the client's queue.
public bool ShouldRun()
{
var mounted = Mobile.Mounted || Mobile.Flying;
var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay;
var pace = EffectiveStepDelay() * 1000;
if (pace >= walkDelay)
{
stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay);
return false;
}
NextMove += Math.Max(50, (long)(stepDelay * 1000));
var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay;
if (Core.TickCount - NextMove > 0)
{
NextMove = Core.TickCount;
}
return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay;
}
// One step per period, paced from the step just taken — no debt accrual: repaying a
// late step with a quicker follow-up puts two steps ~100ms apart, which renders as a
// dart. In continuous pursuit the move-wake lands within wheel resolution of this
// deadline, so the only cost is single-digit-ms drift per step.
private void ConsumeMoveBudget()
{
NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000));
}
public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves);
@ -108,6 +127,8 @@ public abstract partial class BaseAI
return MoveResult.BadState;
}
d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0);
if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
{
Mobile.Direction = d;
@ -118,18 +139,17 @@ public abstract partial class BaseAI
if (TryMove(d))
{
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
if (Core.AOS && IsFollowingMaster())
// Obeying pets are paced by their order handlers.
if (!IsObeyingMoveOrder())
{
Mobile.CurrentSpeed = 0.1;
}
else if (Mobile.Warmode || Mobile.Combatant != null)
{
Mobile.SetCurrentSpeedToActive();
}
else
{
Mobile.SetCurrentSpeedToPassive();
if (Mobile.Warmode || Mobile.Combatant != null)
{
Mobile.SetCurrentSpeedToActive();
}
else
{
Mobile.SetCurrentSpeedToPassive();
}
}
ConsumeMoveBudget();
@ -335,7 +355,7 @@ public abstract partial class BaseAI
/// best-distance stall counter idles the creature if an in-range goal is genuinely
/// unreachable, without ever abandoning a real chase or detour.
/// </summary>
protected bool ApproachTarget(Mobile target, bool run, int range)
protected bool ApproachTarget(Mobile target, int range)
{
if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false)
{
@ -362,7 +382,7 @@ public abstract partial class BaseAI
ResetApproach(); // target moved — try again fresh
}
RenewMoveIntent(target, null, run, range);
RenewMoveIntent(target, null, range);
// FAST PATH: greedy step toward the target, counted as success ONLY when the move
// fully succeeded (not an auto-turn sidestep) and actually got us closer. An
@ -374,7 +394,7 @@ public abstract partial class BaseAI
if (Path == null && Mobile.InLOS(target))
{
var distBefore = Mobile.GetDistanceToSqrt(target);
var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true);
var res = DoMoveImpl(Mobile.GetDirectionTo(target), true);
if (res == MoveResult.BadState)
{
@ -403,7 +423,7 @@ public abstract partial class BaseAI
var couldMove = CanMoveNow(out _) && !IsInBadState();
var locBefore = Mobile.Location;
if (Path.Follow(run, range))
if (Path.Follow(range))
{
ResetApproach();
return true;
@ -422,7 +442,7 @@ public abstract partial class BaseAI
/// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around
/// obstacles. Returns false on arrival or when genuinely unable to make progress.
/// </summary>
public bool MoveToPoint(IPoint3D goal, bool run)
public bool MoveToPoint(IPoint3D goal)
{
if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null)
{
@ -435,12 +455,12 @@ public abstract partial class BaseAI
Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl };
}
RenewMoveIntent(null, goal, run, 1);
RenewMoveIntent(null, goal, 1);
var couldMove = CanMoveNow(out _) && !IsInBadState();
var locBefore = Mobile.Location;
if (Path.Follow(run, 1))
if (Path.Follow(1))
{
Path = null;
ClearMoveIntent();
@ -516,11 +536,10 @@ public abstract partial class BaseAI
_approachGaveUp = false;
}
private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range)
private void RenewMoveIntent(Mobile target, IPoint3D point, int range)
{
_moveIntentTarget = target;
_moveIntentPoint = point;
_moveIntentRun = run;
_moveIntentRange = range;
// A live pursuit renews every think tick; unrenewed intent dies on its own.
@ -541,8 +560,7 @@ public abstract partial class BaseAI
{
nextMove = NextMove;
return (_moveIntentTarget != null || _moveIntentPoint != null) &&
Core.TickCount - _moveIntentExpire < 0;
return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0;
}
/// <summary>
@ -558,26 +576,21 @@ public abstract partial class BaseAI
if (_moveIntentTarget != null)
{
ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange);
ApproachTarget(_moveIntentTarget, _moveIntentRange);
}
else
{
MoveToPoint(_moveIntentPoint, _moveIntentRun);
MoveToPoint(_moveIntentPoint);
}
}
public virtual bool MoveTo(Mobile m, bool run, int range)
public virtual bool MoveTo(Mobile m, int range)
{
if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false)
{
return false;
}
var distance = (int)Mobile.GetDistanceToSqrt(m);
var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5;
var shouldRun = run && distance > distanceThreshold;
if (Mobile.InRange(m, range))
{
ResetApproach();
@ -586,10 +599,10 @@ public abstract partial class BaseAI
if (UseGroupMovement(m, range))
{
return MoveToWithGroup(this, m, shouldRun, range);
return MoveToWithGroup(this, m, range);
}
return ApproachTarget(m, shouldRun, range);
return ApproachTarget(m, range);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@ -599,12 +612,15 @@ public abstract partial class BaseAI
Mobile.ControlTarget == Mobile.ControlMaster &&
Mobile.Combatant == null;
private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range)
// A pet executing a movement order outside combat; its order handler owns its speed.
[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, int range)
{
var distance = (int)Mobile.GetDistanceToSqrt(target);
var shouldRun = run && distance > 5;
var direction = Mobile.GetDirectionTo(target);
// Wall-slide auto-turns must not count as progress, or a creature pinned on
@ -635,10 +651,10 @@ public abstract partial class BaseAI
// Tactical sidesteps exhausted — route around the obstacle via the centralized
// approach primitive (persistent PathFollower, no oscillation).
return ApproachTarget(target, shouldRun, range);
return ApproachTarget(target, range);
}
public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax)
public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax)
{
if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null)
{
@ -649,14 +665,12 @@ 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, shouldRun, iCurrDist, iWantDistMax))
if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax))
{
return false;
}
@ -667,18 +681,16 @@ public abstract partial class BaseAI
return dist >= iWantDistMin && dist <= iWantDistMax;
}
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax)
{
var shouldRun = run && iCurrDist > 5;
if (iCurrDist > iWantDistMax)
{
// Too far: approach via the centralized progress-based primitive.
return ApproachTarget(m, shouldRun, iWantDistMax);
return ApproachTarget(m, iWantDistMax);
}
// 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), true))
{
Path = null;
return true;

View file

@ -26,11 +26,13 @@ public sealed class AITimer : Timer
{
private readonly BaseAI _owner;
private long _nextThink;
private long _nextWake; // when the pending wheel entry fires
private bool _inTick;
private int _detectHiddenMinDelay;
private int _detectHiddenMaxDelay;
public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)),
TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
// The initial delay is irrelevant: Activate is the only start path and sets its own.
public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
{
_owner = owner;
_owner._nextDetectHidden = Core.TickCount;
@ -40,8 +42,34 @@ public sealed class AITimer : Timer
public void Activate()
{
_nextThink = Core.TickCount;
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
if (Running)
{
return;
}
// Short random spread: the creature responds within a think while a sector's
// worth of timers avoids a same-tick burst; the idle think jitter keeps the
// cohort apart from there.
Delay = TimeSpan.FromMilliseconds(Utility.Random(256));
Start();
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
}
// Think now. A think grants no action: steps, swings, casts, and abilities keep their own gates.
public void Prod()
{
_nextThink = Core.TickCount;
if (Running)
{
Reschedule();
return;
}
Delay = TimeSpan.Zero;
Start();
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
}
// A speed-up must not wait out a stale, longer think deadline.
@ -52,12 +80,53 @@ public sealed class AITimer : Timer
if (candidate - _nextThink < 0)
{
_nextThink = candidate;
Reschedule();
}
}
// Moves the pending wake earlier. Interval is only read after the next fire,
// so this needs Stop, Delay = remaining, Start.
private void Reschedule()
{
if (_inTick || !Running)
{
return; // ScheduleNext handles it 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; // pending wake is already early enough
}
Stop();
Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now));
Start();
_nextWake = now + (long)Delay.TotalMilliseconds;
}
protected override void OnTick()
{
_inTick = true;
try
{
OnTickCore();
}
finally
{
_inTick = false;
}
}
private void OnTickCore()
{
if (ShouldStop())
{
@ -83,7 +152,18 @@ public sealed class AITimer : Timer
}
// Cadence from the post-decision speed (decisions may flip active/passive).
_nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000);
var period = (long)(_owner.Mobile.CurrentSpeed * 1000);
_nextThink = Core.TickCount + period;
// Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so
// creatures spawned or woken together cannot stay in lock-step (a one-shot
// spread can collide and identical periods never separate). Engaged cadence
// stays exact — pursuit timing anchors to real step times.
if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed)
{
var jitter = (int)(period >> 3);
_nextThink += Utility.RandomMinMax(-jitter, jitter);
}
}
else
{
@ -111,6 +191,7 @@ 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.Start();
AITimer.Activate();
}
if (Action != ActionType.Wander)
@ -442,7 +442,7 @@ public abstract partial class BaseAI
var master = Mobile.SummonMaster;
if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception))
{
MoveTo(master, false, 1);
MoveTo(master, 1);
}
}
@ -592,7 +592,7 @@ public abstract partial class BaseAI
}
_lkpGoal ??= _lkpLocation;
return MoveToPoint(_lkpGoal, false);
return MoveToPoint(_lkpGoal);
}
private void ClearLastKnown()
@ -644,7 +644,7 @@ public abstract partial class BaseAI
_herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
}
MoveToPoint(_herdGoal, false);
MoveToPoint(_herdGoal);
return true;
}
@ -798,7 +798,7 @@ public abstract partial class BaseAI
{
if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
{
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2))
{
DebugSay("I backed off to safety. Wandering...");
@ -850,22 +850,24 @@ public abstract partial class BaseAI
return false;
}
if (Core.TickCount - Mobile.NextReacquireTime < 0)
var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds;
var gateRemaining = Mobile.NextReacquireTime - Core.TickCount;
if (gateRemaining > 0 && gateRemaining <= reacquireDelay)
{
Mobile.FocusMob = null;
return false;
}
Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds;
DebugSay("Acquiring new target...", 0);
DebugSay("Acquiring new target...");
var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
if (Mobile.Map == null)
{
return Mobile.FocusMob != null;
}
// Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach),
// not this poll — every scan honors the full delay.
Mobile.NextReacquireTime = Core.TickCount + reacquireDelay;
return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
return acquired;
}
private bool HandleBardProvoked()
@ -941,8 +943,10 @@ public abstract partial class BaseAI
private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe)
{
Mobile newFocusMob = null, enemySummonMob = null;
double val = double.MinValue, enemySummonVal = double.MinValue;
Mobile newFocusMob = null;
Mobile enemySummonMob = null;
var val = double.MinValue;
var enemySummonVal = double.MinValue;
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
{

View file

@ -26,7 +26,7 @@ public abstract partial class BaseAI
return;
}
Activate();
AITimer.Prod();
switch (Mobile.ControlOrder)
{
@ -36,6 +36,10 @@ public abstract partial class BaseAI
break;
}
case OrderType.Come:
{
Mobile.SetCurrentSpeedToActive();
break;
}
case OrderType.Drop:
case OrderType.Friend:
case OrderType.Unfriend:
@ -135,6 +139,7 @@ public abstract partial class BaseAI
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToPassive();
}
private void HandleTransferOrder()
@ -148,6 +153,7 @@ public abstract partial class BaseAI
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToPassive();
Mobile.PlaySound(Mobile.GetIdleSound());
_commandIssuer = null;
}
@ -162,9 +168,16 @@ public abstract partial class BaseAI
_commandIssuer?.RevealingAction();
Mobile.FocusMob = null;
Mobile.Warmode = true;
Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you.
Mobile.SetCurrentSpeedToActive();
// Resuming the persistent order must not replay the flourish.
if (!_resolvingOrder)
{
Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you.
}
_commandIssuer = null;
}
@ -191,6 +204,7 @@ public abstract partial class BaseAI
}
Mobile.Warmode = true;
Mobile.SetCurrentSpeedToActive();
Mobile.PlaySound(Mobile.GetAttackSound());
_commandIssuer = null;
}
@ -206,6 +220,7 @@ public abstract partial class BaseAI
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToActive();
Mobile.PlaySound(Mobile.GetIdleSound());
_commandIssuer = null;
}
@ -221,6 +236,7 @@ public abstract partial class BaseAI
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToPassive();
Mobile.PlaySound(Mobile.GetIdleSound());
_commandIssuer = null;
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.

View file

@ -84,7 +84,7 @@ public abstract partial class BaseAI
return true;
}
WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2);
WalkMobileRange(Mobile.ControlMaster, 1, 1, 2);
if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2)
{
@ -128,9 +128,15 @@ public abstract partial class BaseAI
this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}.");
// AOS: sprint after the master (bespoke 0.1 paces both clocks).
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);
WalkMobileRange(Mobile.ControlTarget, 1, 1, 2);
}
}
@ -291,14 +297,13 @@ public abstract partial class BaseAI
return true;
}
FindCombatant();
var combatant = FindGuardTarget();
if (IsValidCombatant(Mobile.Combatant))
if (combatant != null)
{
var combatant = Mobile.Combatant;
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
// Engage without leaving the Guard order so tags, recall handling, and retargeting persist.
Mobile.Combatant = combatant;
Mobile.FocusMob = combatant;
Action = ActionType.Combat;
@ -309,16 +314,30 @@ public abstract partial class BaseAI
{
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
var guardLocation = controlMaster.Location;
// Stand down; a stale Warmode would skew the return pace.
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
if (distance > 3)
{
DoMove(Mobile.GetDirectionTo(guardLocation));
// AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active.
if (Core.AOS)
{
Mobile.CurrentSpeed = 0.1;
}
else
{
Mobile.SetCurrentSpeedToActive();
}
WalkMobileRange(controlMaster, 1, 1, 3);
}
else
{
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
WalkRandom(3, 1, 1);
}
}
@ -359,67 +378,83 @@ public abstract partial class BaseAI
Mobile.ControlTarget = Mobile.ControlMaster;
ResumePersistentOrder();
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
// A resumed Guard engages through its own scan; other fallbacks chain 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 closest to the master. The current combatant is kept
/// unless a strictly closer one exists. Never mutates order state.
/// </summary>
private Mobile FindGuardTarget()
{
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 (!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;
}
var isAttackingPet = aggr.Combatant == Mobile;
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
var dist = aggr.GetDistanceToSqrt(anchor);
if (isAttackingPet || isAttackingMaster)
if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
{
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;
}
best = aggr;
bestDist = dist;
}
}
if (controlMaster?.Aggressors != null)
{
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
{
var aggressor = controlMaster.Aggressors[i].Attacker;
var aggressors = controlMaster?.Aggressors;
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;
}
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;
Mobile.ControlOrder = OrderType.Attack;
Mobile.Combatant = aggressor;
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
Think();
return;
best = aggressor;
bestDist = dist;
}
}
}
return best;
}
public virtual bool DoOrderRelease()

View file

@ -12,7 +12,7 @@ public class BerserkAI : BaseAI
if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true))
{
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack");
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
Mobile.Combatant = Mobile.FocusMob;
Action = ActionType.Combat;
@ -38,7 +38,7 @@ public class BerserkAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
{
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");

View file

@ -81,7 +81,7 @@ public class HealerAI : BaseAI
return true;
}
WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7);
WalkMobileRange(Mobile.FocusMob, 1, 4, 7);
// TODO: Should it be able to do this?
if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant))

View file

@ -171,7 +171,7 @@ public class MageAI : BaseAI
{
if (!SmartAI)
{
if (!MoveTo(m, false, Mobile.RangeFight))
if (!MoveTo(m, Mobile.RangeFight))
{
OnFailedMove();
}
@ -185,14 +185,14 @@ public class MageAI : BaseAI
{
RunFrom(m);
}
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1))
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1))
{
OnFailedMove();
}
}
else if (!Mobile.InRange(m, Mobile.RangeFight))
{
if (!MoveTo(m, false, 1))
if (!MoveTo(m, 1))
{
OnFailedMove();
}
@ -701,7 +701,7 @@ public class MageAI : BaseAI
{
DebugSay("I cannot see my target, moving to regain line of sight");
if (!MoveTo(c, false, 1))
if (!MoveTo(c, 1))
{
OnFailedMove();
}
@ -1039,7 +1039,7 @@ public class MageAI : BaseAI
// target can be invoked.
if (!Mobile.InLOS(toTarget))
{
MoveTo(toTarget, true, 1);
MoveTo(toTarget, 1);
}
else
{

View file

@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;
namespace Server.Mobiles;
public class MeleeAI : BaseAI
@ -14,6 +16,7 @@ public class MeleeAI : BaseAI
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
{
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
Mobile.Combatant = Mobile.FocusMob;
Action = ActionType.Combat;
}
@ -65,13 +68,9 @@ public class MeleeAI : BaseAI
return true;
}
private bool IsValidCombatant(Mobile combatant)
{
return combatant?.Deleted == false
&& combatant.Map == Mobile.Map
&& combatant.Alive
&& !combatant.IsDeadBondedPet;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsValidCombatant(Mobile combatant) =>
combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet;
private bool HandleOutOfRangeCombatant(Mobile combatant)
{
@ -99,7 +98,7 @@ public class MeleeAI : BaseAI
private bool AttemptMoveToCombatant(Mobile combatant)
{
if (MoveTo(combatant, false, Mobile.RangeFight))
if (MoveTo(combatant, Mobile.RangeFight))
{
return true;
}
@ -127,7 +126,8 @@ public class MeleeAI : BaseAI
{
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
{
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking.");
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
Mobile.Combatant = Mobile.FocusMob;
Action = ActionType.Combat;
}

View file

@ -41,7 +41,7 @@ public class PredatorAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
{
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
{
@ -70,7 +70,7 @@ public class PredatorAI : BaseAI
}
else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
{
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2))
{
DebugSay("Well, here I am safe");

View file

@ -43,7 +43,7 @@ public class ThiefAI : BaseAI
return true;
}
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
{
this.DebugSayFormatted($"I should be closer to {combatant.Name}");
}

View file

@ -752,8 +752,9 @@ namespace Server.Mobiles
/// <summary>
/// Resolved seconds per step: a verbatim active/passive <see cref="CurrentSpeed"/>
/// maps to the matching movement value; a bespoke pace stays fused to both clocks.
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>.
/// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint)
/// stays fused to both clocks. A herded creature is always driven at
/// <see cref="HerdingMoveSpeed"/>.
/// </summary>
[CommandProperty(AccessLevel.GameMaster)]
public double CurrentMoveSpeed
@ -847,6 +848,8 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public Point3D ControlDest { get; set; }
// Fires on every assignment, not only changes: a reissued order is a command
// (retarget, break off combat, re-anchor Home). Handlers receive the previous order.
[CommandProperty(AccessLevel.GameMaster)]
public OrderType ControlOrder
{
@ -935,21 +938,50 @@ namespace Server.Mobiles
public virtual bool GivesMLMinorArtifact => false;
/* To save on cpu usage, RunUO creatures only reacquire creatures under the following circumstances:
* - 10 seconds have elapsed since the last time it tried
* - The creature was attacked
* - Some creatures, like dragons, will reacquire when they see someone move
*
* This functionality appears to be implemented on OSI as well
*/
public long NextReacquireTime { get; set; }
public virtual TimeSpan ReacquireDelay => TimeSpan.FromSeconds(10.0);
public virtual bool ReacquireOnMovement => false;
public virtual bool AcquireOnApproach => m_Paragon;
// Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the
// next scan to at most this far away. Zero (paragons) scans on the very next
// think; larger is dumber; pure ReacquireDelay is the oblivious floor.
public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0);
// Reactive range is tighter than the periodic scan's RangePerception: approach
// aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep.
public virtual int AcquireOnApproachRange => 10;
// Clamps the scan deadline rather than opening the gate: repeated steps cannot
// shorten it further, so an armed creature scans once per delay period.
private void ScheduleAcquireOnApproach()
{
var delay = (long)AcquireOnApproachDelay.TotalMilliseconds;
var deadline = Core.TickCount + delay;
if (deadline - NextReacquireTime < 0)
{
NextReacquireTime = deadline;
}
if (delay <= 0)
{
// Zero: think now — the ranked scan engages within a wheel turn. Prod is
// spam-safe; the Combatant == null guard stops the prods once engaged.
AIObject?.AITimer?.Prod();
}
}
// IsEnemy first — it cheaply rejects the common case (a same-team wild creature
// wandering past); CanBeHarmful covers hidden movers via CanSee.
private bool ShouldAcquireOnApproach(Mobile m) =>
Combatant == null &&
!Controlled && !Summoned && !BardPacified &&
FightMode != FightMode.None && FightMode != FightMode.Aggressor &&
InRange(m.Location, AcquireOnApproachRange) &&
IsEnemy(m) && CanBeHarmful(m, false);
public virtual bool ReacquireOnMovement => false;
public static bool Summoning { get; set; }
public virtual bool IsDispellable => Summoned && !IsAnimatedDead;
@ -2023,6 +2055,8 @@ namespace Server.Mobiles
{
base.Deserialize(reader);
NextReacquireTime = Core.TickCount;
var version = reader.ReadInt();
m_CurrentAI = (AIType)reader.ReadInt();
@ -2854,15 +2888,9 @@ namespace Server.Mobiles
public override void OnMovement(Mobile m, Point3D oldLocation)
{
if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor)
if (ShouldAcquireOnApproach(m))
{
if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) &&
CanBeHarmful(m) && IsEnemy(m))
{
Combatant = FocusMob = m;
AIObject?.MoveTo(m, true, 1);
DoHarmful(m);
}
ScheduleAcquireOnApproach();
}
else if (ReacquireOnMovement)
{
@ -3097,14 +3125,12 @@ namespace Server.Mobiles
return base.OnBeforeDeath();
}
public int ComputeBonusDamage(List<DamageEntry> list, Mobile m)
public int ComputeBonusDamage(in ValueLinkList<DamageEntry> list, Mobile m)
{
var bonus = 0;
for (var i = list.Count - 1; i >= 0; --i)
foreach (var de in list.ByDescending())
{
var de = list[i];
if (de.Damager == m || de.Damager is not BaseCreature bc)
{
continue;
@ -3141,26 +3167,15 @@ namespace Server.Mobiles
Combatant is PlayerMobile ||
Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile;
public static List<DamageStore> GetLootingRights(List<DamageEntry> damageEntries, int hitsMax)
// Iterates most recent first, matching the previous reverse-indexed loop. The list is
// already pruned of expired entries by the Mobile.DamageEntries getter.
public static List<DamageStore> GetLootingRights(in ValueLinkList<DamageEntry> damageEntries, int hitsMax)
{
var rights = new List<DamageStore>();
DamageStore firstDamager = null;
for (var i = damageEntries.Count - 1; i >= 0; --i)
foreach (var de in damageEntries.ByDescending())
{
if (i >= damageEntries.Count)
{
continue;
}
var de = damageEntries[i];
if (de.HasExpired)
{
damageEntries.RemoveAt(i);
continue;
}
var damage = de.DamageGiven;
var respList = de.Responsible;

View file

@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature
Hidden = m_LastHidden = master.Hidden;
}
if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true)
if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true)
{
Warmode = master.Warmode;
Combatant = master.Combatant;

View file

@ -108,7 +108,7 @@ namespace Server.Mobiles
*/
else if (!Combat(this))
{
AIObject?.MoveTo(SummonMaster, false, 5);
AIObject?.MoveTo(SummonMaster, 5);
}
/*
On OSI, if the summon attacks a mobile, the summoner meer also

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Collections;
using Server.Engines.CannedEvil;
using Server.Engines.Virtues;
using Server.Items;

View file

@ -86,11 +86,17 @@ public static class IncomingPlayerPackets
public static void HuePickerResponse(NetState state, SpanReader reader)
{
var serial = reader.ReadUInt32();
_ = reader.ReadInt16(); // Item ID
reader.ReadInt16(); // Item ID
var hue = Utility.ClipDyedHue(reader.ReadInt16() & 0x3FFF);
foreach (var huePicker in state.HuePickers)
if (state.HuePickers == null)
{
return;
}
for (var i = 0; i < state.HuePickers.Count; i++)
{
var huePicker = state.HuePickers[i];
if (huePicker.Serial == serial)
{
state.RemoveHuePicker(huePicker);
@ -287,13 +293,18 @@ public static class IncomingPlayerPackets
public static void MenuResponse(NetState state, SpanReader reader)
{
var serial = reader.ReadUInt32();
int menuID = reader.ReadInt16();
reader.ReadInt16(); // menu id
int index = reader.ReadInt16();
int itemID = reader.ReadInt16();
int hue = reader.ReadInt16();
reader.ReadInt16(); // item id
reader.ReadInt16(); // hue
index -= 1; // convert from 1-based to 0-based
if (state.Menus == null)
{
return;
}
for (var i = 0; i < state.Menus.Count; i++)
{
var menu = state.Menus[i];

View file

@ -238,10 +238,7 @@ namespace Server.Mobiles
if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true)
{
var iCurrDist = (int)Mobile.GetDistanceToSqrt(master);
var bRun = iCurrDist > 5;
WalkMobileRange(master, 2, bRun, 0, 1);
WalkMobileRange(master, 2, 0, 1);
}
else
{

View file

@ -29,6 +29,9 @@ description: >
- `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default)
- `Name = "text"` -> `public override string DefaultName => "text";`
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;`
- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement
- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition
- `DamageEntries` is an inline `ref readonly ValueLinkList<DamageEntry>`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries
## Anti-Patterns
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)

View file

@ -27,8 +27,18 @@ description: >
(`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move
(`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until
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
AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is
derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument --
see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching
enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s
default, `ReacquireDelay`-only = oblivious) -- see § Target Acquisition
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,6 +283,91 @@ 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).
The client's `Running` bit is derived from the step pace, never passed by callers
(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk
interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` /
`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated
step (resuming after at least a walk interval standing) goes out as a walk regardless of
pace — the client renders each step alone, so a run-flagged single step darts — unless the
pace beats the run interpolation (a true sprinter), where a walk-rendered first step would
flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`,
`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it
fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just
taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace.
### Target Acquisition: the reaction-time gradient
Acquisition is event-driven, not polled. The periodic scan (`AcquireFocusMob`) is gated by
`ReacquireDelay` (10 s default) and every scan re-arms it in full, success or failure — it
is target stickiness plus the fallback for what movement cannot signal (reveals, doors,
summons). Reaction time comes from `BaseCreature.OnMovement`: an enemy moving inside
`AcquireOnApproachRange` (10 — on-screen; the periodic scan keeps the wider
`RangePerception`) clamps the next scan to
at most **`AcquireOnApproachDelay`** — the intelligence gradient. `TimeSpan.Zero`
(paragons) also prods the AI, so the ranked scan engages within a timer-wheel turn; the
2 s default reads as "took a beat to notice you"; larger is dumber; a creature that
overrides the delay above `ReacquireDelay` is effectively oblivious to approach. Repeated
steps cannot shorten the clamp, so an armed creature scans once per delay period, not once
per step or think. `ReacquireOnMovement` remains the broader hook (any mover, no enemy
check, scan next think). The gate self-heals: a deadline further out than `ReacquireDelay`
is illegal and reads as open, so no wedged or wrapped value can silence acquisition beyond
one delay period.
### 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

View file

@ -474,6 +474,88 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha
| `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` |
| `get { return value; }` | `=> value;` expression-bodied |
## AI Movement: No `run` Argument
RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in
combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the
client's per-step animation time, so ModernUO derives it from the creature's step pace
(`BaseAI.ShouldRun`) and the parameter is gone:
```csharp
// RunUO
MoveTo(combatant, true, m_Mobile.RangeFight);
WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1);
// ModernUO
MoveTo(combatant, Mobile.RangeFight);
WalkMobileRange(Mobile.ControlMaster, 1, 0, 1);
```
`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way.
To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged.
An isolated step (after the creature stood for at least a walk interval) goes out as a
walk regardless of pace — only a continuing cadence, or a pace faster than the run
interpolation, flags run.
## Target Acquisition: `AcquireOnApproach` Is a Delay
RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now
`AcquireOnApproachDelay`, a `TimeSpan` reaction-time gradient that applies to every
creature — enemy movement inside `AcquireOnApproachRange` schedules a scan within the
delay instead of waiting out the 10 s `ReacquireDelay` poll:
```csharp
// RunUO
public override bool AcquireOnApproach => true;
// ModernUO — Zero is the old instant behavior; larger values are dumber
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
```
`AcquireOnApproachRange` stays 10 for all creatures (reactive aggro is on-screen; the
periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The
acquired target comes from the normal FightMode-ranked scan, not from whichever mobile
happened to move. See `content-patterns.md` § Target Acquisition.
## Damage Entries: Inline `ValueLinkList`, Not `List<DamageEntry>`
RunUO's `Mobile.DamageEntries` was a `List<DamageEntry>` allocated for every mobile.
ModernUO keeps damage entries in an inline `ValueLinkList<DamageEntry>` struct held by
the mobile itself, ordered least recent → most recent, so a mobile that never takes
damage owns no list object and `RegisterDamage` relinks in O(1). The property is
`ref readonly`; expired entries are pruned when it is read.
```csharp
// RunUO
for (var i = m.DamageEntries.Count - 1; i >= 0; --i)
{
var de = m.DamageEntries[i]; // indexer
...
}
m.DamageEntries.Clear();
var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // List<DamageEntry>
// ModernUO — needs `using Server.Collections;` for the enumerator extensions
foreach (var de in m.DamageEntries.ByDescending()) // most recent first
{
...
}
foreach (var de in m.DamageEntries) // least recent first
{
...
}
m.ClearDamageEntries();
var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // in ValueLinkList<DamageEntry>
```
What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and
passing the property where a `List<DamageEntry>` is expected. `.Count`,
`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and
`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList`
link fields; never set them yourself, and never call a `ValueLinkList` mutator on the
`ref readonly` property — it compiles against a copy and corrupts the node's link state.
Mutate only through `RegisterDamage` and `ClearDamageEntries`.
## Item Name Changes
```csharp

View file

@ -130,6 +130,13 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
| `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same |
| `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` |
| `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters |
| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) |
| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same |
| `PathFollower.Follow(run, range)` | `Follow(range)` | Same |
| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior |
| `m.DamageEntries` (`List<DamageEntry>`) | `m.DamageEntries` (`ref readonly ValueLinkList<DamageEntry>`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` |
| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | |
| `GetLootingRights(List<DamageEntry>, int)` | `GetLootingRights(in ValueLinkList<DamageEntry>, int)` | Callers passing `m.DamageEntries` compile unchanged |
## Networking