Compare commits

..

11 commits

Author SHA1 Message Date
Kamron Batman
a082202e98
refactor: serialize one master reference; fold the SummonMaster lockstep
ControlMaster and SummonMaster, when both set, are always the same
mobile: BaseCreature.Summon assigns both to the caster, and every pet
management flow (transfer, stable, claim, ball of summoning, GM obey,
login overflow) followed SetControlMaster with an identical SummonMaster
assignment. They differ only in presence - uncontrolled summons carry
only a summon master, pets only a control master.

So one _master reference serializes (refreshed at save, fanned back out
through the Controlled/Summoned flags in AfterDeserialization; legacy
loads feed the same path), and SetControlMaster now keeps SummonMaster
in lockstep itself, deleting the six hand-rolled copies of that
boilerplate.

Also: a creature constructed without speeds (missing npc-speeds.json)
now logs debug and defaults to Medium (0.25/0.5) instead of throwing -
this is the place a sane default belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:07:35 -07:00
Kamron Batman
6d7eb24cc1
test: drop the fossilized v22 legacy-stream test
It served its purpose validating the migration during development; the
legacy path is one-time upgrade code and the replica writer was most of
the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:18:07 -07:00
Kamron Batman
cdcf82cfd8
refactor: None means custom; the type list resolves only at construction
The None -> type list -> Medium chain was construction-time defaulting
(so creatures without a bucket or a SetSpeed call never spawn at 0/0),
not a live semantic. The constructor now resolves it once into
_speedClass itself, so at runtime a concrete bucket means table-backed
and None means the creature's own speeds are authoritative - which is
what SpeedLevel.Custom was; it is removed. Runtime entry resolution
collapses to a single level lookup, and the SpeedClass byte still
elides by comparing against the (cached) resolved type default.

Legacy loads guess the type default and demote to None when the loaded
speeds do not conform, so pre-codegen customized creatures (SetSpeed
vendors) come out honestly labeled. A constructor guard keeps a missing
speed table loud instead of spawning 0-delay creatures that spin their
AI timers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:13:46 -07:00
Kamron Batman
4238980c6d
feat: treat the four speeds as one block against the bucket
Either all four values (active/passive think + move) conform to the
creature's speed entry - elided as a set - or the creature is fully
custom and all four serialize. Partial conformance cannot exist on the
wire, so no value is ever left silently tracking the table beside a
hand-tuned sibling.

"Fully custom" is a real state: SpeedLevel.Custom (None already means
"resolve by type list" for the type-listed species, so it cannot double
as the custom marker). Tuning any speed flips the bucket to Custom (the
label never lies), Custom resolves no entry and short-circuits the
conformance check, a custom creature is its own GetSpeeds reference
(paragon snap becomes a natural no-op), and assigning a real bucket
un-customs it via ApplySpeedClass. A re-entrancy guard keeps the flip
from misreading ApplySpeedClass's half-assigned block, and constructors
seed raw fields so DefaultSpeedClass types do not flip at birth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:05:51 -07:00
Kamron Batman
695efc7d6e
feat: stateful SpeedClass; the type constant moves to DefaultSpeedClass
A virtual SpeedClass could be overridden dynamically (boss state change)
and silently diverge from the cached speed entry. SpeedClass is now
non-virtual instance state: assigning it invalidates the cached entry,
applies the new bucket's think and move speeds (preserving the
active/passive mode), and serializes only when it differs from the
type's DefaultSpeedClass - so a runtime bucket change survives a save
while its (bucket-matching) speeds still elide. Works from [props too.

Overrides become: DefaultSpeedClass for a type's constant bucket,
SpeedClass assignment for state changes, GetSpeeds/GetMoveSpeeds to
bypass the table entirely - none of which can leave the cache stale.

SpeedClass deserializes before the speed fields (index 7; later indexes
shift by one - v23 was never released, schema regenerated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:50:17 -07:00
Kamron Batman
f2f8313b42
perf: cache the resolved speed entry per creature
Serialization consults the speed table four times per mob per save (the
Should* flag checks) and again through the Default* methods on elided
loads - each a SpeedClass/type dictionary walk. The resolved
SpeedClassEntry is now cached on the creature (one reference; the table
is immutable after Configure), so those become a null-check and field
reads. GetSpeeds/GetMoveSpeeds stay the virtual override point, so
stubs and forks that override them still steer elision; only their
default implementations read the cache. NPCSpeeds' per-call lookups
collapse into FindEntry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:40:03 -07:00
Kamron Batman
f46780d554
refactor: DamageMin/DamageMax need not be virtual either
No overrides exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:35:44 -07:00
Kamron Batman
93b9238f6c
refactor: sweep BaseCreature comments to concise constraints
Removes narrative and developer commentary (historical essays, changelog
notes, say-what-the-code-does lines, flavor) and condenses the keepers:
constraint statements, wire-format markers, era-behavior notes, and the
weighted-random distribution table. TODOs use the terse house style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:35:02 -07:00
Kamron Batman
232c5ed868
Removes virtual 2026-08-23 12:29:06 -07:00
Kamron Batman
963b9b3b85
refactor: collapse the move-speed properties into serialized fields
ActiveMoveSpeed/PassiveMoveSpeed become plain [SerializableField]s (not
virtual): the properties now read the raw override (0 = inheriting) and
CurrentMoveSpeed carries the inherit resolution - it was the only
production reader of the resolving getters. The <=0 coercion moves to an
allowFieldChange hook. Wire format unchanged (schema diff is empty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:27:56 -07:00
Kamron Batman
f4327e6a3a
refactor: BaseCreature to the SerializationGenerator with SaveFlag elision
Converts BaseCreature's hand-written v22 serialization to codegen v23.
Nearly every field sits behind a [SaveFlag], so a creature matching its
defaults writes only [version int][ulong flags][default AI] - 13 bytes -
instead of ~236, and the writer's work is mostly branch-not-taken. With
500k-1M creatures in a world, this is the dominant slice of mobile save
freeze time and disk.

- Speeds serialize only when they differ from the creature's npc-speeds
  values (GetSpeeds/GetMoveSpeeds on both the flag check and the load
  default), so table edits now reach existing unmodified spawns on
  restart, and former paragons (snapped back to table values) elide
  fully. CurrentSpeed writes only when it differs from PassiveSpeed.
- The delete countdown is a [DeserializeTimer] field (anchored);
  stabled/controlled pets never persist one, and the abandoned-pet
  3-day fallback lives in AfterDeserialization for both load paths.
- SummonEnd stays anchored, written only while summoned.
- Old saves (v0-22) load through the retained legacy
  Deserialize(reader, version), now assigning raw fields; the shared
  post-load fixups (stat timers, AI creation, followers, animate-dead
  registration, unsummon timer) moved to [AfterDeserialization].
- Side-effect setters became generated-field hooks (Team, Controlled,
  Summoned, Loyalty clamp, resistance seeds, CurrentSpeed); properties
  whose semantics the hooks cannot express stay hand-written as
  [SerializableProperty] (ControlMaster/SummonMaster bracket the
  assignment with follower bookkeeping, ControlOrder must run on equal
  re-assignment, Tamable/IsParagon/move speeds have custom getters).
- CreatureDeathEvent/CreatureDeletedEvent moved to a CreatureEvents
  host class: the events generator and the serialization generator each
  emit a [GeneratedCode] partial for the declaring type, and the
  attribute forbids duplicates (CS0579).
- m_ fields renamed to _camelCase.

Tests: new-format round trips (default and fully populated) with exact
byte consumption, back-to-back saves byte-identical, and a
byte-authentic fossilized v22 stream loading through the legacy path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:50:38 -07:00
59 changed files with 2090 additions and 2847 deletions

1
.gitignore vendored
View file

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

View file

@ -1,311 +0,0 @@
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 delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
public class DamageEntry : IValueLinkListNode<DamageEntry> public class DamageEntry
{ {
public DamageEntry(Mobile damager) => Damager = damager; public DamageEntry(Mobile damager) => Damager = damager;
@ -57,11 +57,6 @@ public class DamageEntry : IValueLinkListNode<DamageEntry>
public List<DamageEntry> Responsible { get; set; } public List<DamageEntry> Responsible { get; set; }
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0); 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] [Flags]
@ -382,6 +377,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors = new List<AggressorInfo>(); Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>(); Aggressed = new List<AggressorInfo>();
NextSkillTime = Core.TickCount; NextSkillTime = Core.TickCount;
DamageEntries = new List<DamageEntry>();
} }
// Sectors // Sectors
@ -962,23 +958,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public static VisibleDamageType VisibleDamageType { get; set; } public static VisibleDamageType VisibleDamageType { get; set; }
private ValueLinkList<DamageEntry> _damageEntries; public List<DamageEntry> DamageEntries { get; private set; }
/// <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)] [CommandProperty(AccessLevel.GameMaster)]
public Mobile LastKiller { get; set; } public Mobile LastKiller { get; set; }
@ -1647,7 +1627,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player; 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; } public bool NoMoveHS { get; set; }
@ -2040,7 +2020,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors[i].CanReportMurder = false; Aggressors[i].CanReportMurder = false;
} }
ClearDamageEntries(); // reset damage entries on full HP if (DamageEntries.Count > 0)
{
DamageEntries.Clear(); // reset damage entries on full HP
}
} }
else if (CanRegenHits) else if (CanRegenHits)
{ {
@ -5762,54 +5745,24 @@ 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 Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
public DamageEntry FindMostRecentDamageEntry(bool allowSelf) public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries(); for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if (allowSelf || de.Damager != this) if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (allowSelf || de.Damager != this)
{ {
return de; return de;
} }
@ -5822,11 +5775,21 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries(); for (var i = 0; i < DamageEntries.Count; ++i)
for (var de = _damageEntries._first; de != null; de = de.Next)
{ {
if (allowSelf || de.Damager != this) if (i < 0)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
--i;
}
else if (allowSelf || de.Damager != this)
{ {
return de; return de;
} }
@ -5837,17 +5800,24 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; 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) public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries();
DamageEntry mostTotal = null; DamageEntry mostTotal = null;
for (var de = _damageEntries._last; de != null; de = de.Previous) for (var i = DamageEntries.Count - 1; i >= 0; --i)
{ {
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) 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))
{ {
mostTotal = de; mostTotal = de;
} }
@ -5860,28 +5830,46 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries(); DamageEntry mostTotal = null;
DamageEntry leastTotal = null; for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) if (i >= DamageEntries.Count)
{ {
leastTotal = de; 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;
} }
} }
return leastTotal; return mostTotal;
} }
public DamageEntry FindDamageEntryFor(Mobile m) public DamageEntry FindDamageEntryFor(Mobile m)
{ {
PruneExpiredDamageEntries(); for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if (de.Damager == m) if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (de.Damager == m)
{ {
return de; return de;
} }
@ -5899,13 +5887,8 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
de.DamageGiven += amount; de.DamageGiven += amount;
de.LastDamage = Core.Now; de.LastDamage = Core.Now;
// Move to the tail so the list stays in LastDamage order. DamageEntries.Remove(de);
if (de.OnLinkList) DamageEntries.Add(de);
{
_damageEntries.Remove(de);
}
_damageEntries.AddLast(de);
var master = from.GetDamageMaster(this); var master = from.GetDamageMaster(this);
@ -6495,6 +6478,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_DexLock = (StatLockType)reader.ReadByte(); m_DexLock = (StatLockType)reader.ReadByte();
m_IntLock = (StatLockType)reader.ReadByte(); m_IntLock = (StatLockType)reader.ReadByte();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
if (version < 32) if (version < 32)
{ {
if (reader.ReadBool()) if (reader.ReadBool())
@ -7827,10 +7813,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_FollowersMax = 5; m_FollowersMax = 5;
Skills = new Skills(this); Skills = new Skills(this);
Items = new List<Item>(); Items = new List<Item>();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
Map = Map.Internal; Map = Map.Internal;
AutoPageNotify = true; AutoPageNotify = true;
Aggressors = new List<AggressorInfo>(); Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>(); Aggressed = new List<AggressorInfo>();
DamageEntries = new List<DamageEntry>();
NextSkillTime = Core.TickCount; NextSkillTime = Core.TickCount;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable
{ {
var bc = NewCreature(); var bc = NewCreature();
Assert.Equal(0.3, bc.ActiveMoveSpeed); // 0 = no override; the resolved pace comes from CurrentMoveSpeed.
Assert.Equal(0.6, bc.PassiveMoveSpeed); Assert.Equal(0, bc.ActiveMoveSpeed);
Assert.Equal(0, bc.PassiveMoveSpeed);
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
} }
@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable
bc.SetSpeed(0.2, 0.4); bc.SetSpeed(0.2, 0.4);
Assert.Equal(0.2, bc.ActiveMoveSpeed); Assert.Equal(0, bc.ActiveMoveSpeed);
Assert.Equal(0.4, bc.PassiveMoveSpeed); Assert.Equal(0, bc.PassiveMoveSpeed);
} }
[Fact] [Fact]
@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable
bc.ActiveMoveSpeed = 0; bc.ActiveMoveSpeed = 0;
Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again Assert.Equal(0, bc.ActiveMoveSpeed); // inheriting again
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
bc.SetCurrentSpeedToActive();
Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock
} }
[Fact] [Fact]
@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable
bc.ScaleMoveSpeed(1.0 / 1.2); bc.ScaleMoveSpeed(1.0 / 1.2);
Assert.Equal(0.5, bc.ActiveMoveSpeed); Assert.Equal(0.5, bc.ActiveMoveSpeed);
Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar Assert.Equal(0, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
} }
[Fact] [Fact]
@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable
bc.MigrateMoveSpeeds(); bc.MigrateMoveSpeeds();
Assert.Equal(0.35, bc.ActiveMoveSpeed); Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock
Assert.Equal(0.6, bc.PassiveMoveSpeed); Assert.Equal(0, bc.PassiveMoveSpeed);
} }
[Theory] [Theory]
@ -213,7 +216,7 @@ public class MoveSpeedTests : IDisposable
// The v22 tail is the last block; exact consumption catches any offset mistake. // The v22 tail is the last block; exact consumption catches any offset mistake.
Assert.Equal(buffer.Length, reader.Position); Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed);
Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed);
} }
} }

View file

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

@ -1,162 +0,0 @@
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,281 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles;
// BaseCreature's SaveFlag format round-trips both a default and a fully-populated
// creature with exact byte consumption, and back-to-back saves are byte-identical
// (freeze-time stability).
[Collection("Sequential UOContent Tests")]
public class BaseCreatureSerializationTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
for (var i = 0; i < _created.Count; i++)
{
_created[i].Delete();
}
}
private class CreatureStub : BaseCreature
{
public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9;
public CreatureStub(Serial serial) : base(serial) => Body = 0xC9;
// Stands in for the npc-speeds table (unconfigured in the test fixture).
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
{
activeMoveSpeed = 0.6;
passiveMoveSpeed = 1.2;
}
}
private CreatureStub NewCreature()
{
var bc = new CreatureStub();
_created.Add(bc);
return bc;
}
private static byte[] Snapshot(Mobile m)
{
var writer = new BufferWriter(true);
m.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
return buffer;
}
private CreatureStub Load(byte[] buffer)
{
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position); // exact consumption
return copy;
}
[Fact]
public void DefaultCreature_RoundTrips_AndElidesEverything()
{
var bc = NewCreature();
var buffer = Snapshot(bc);
var copy = Load(buffer);
Assert.Equal(AIType.AI_Melee, copy.AI);
Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception);
Assert.Equal(0.3, copy.ActiveSpeed);
Assert.Equal(0.6, copy.PassiveSpeed);
Assert.Equal(0.6, copy.CurrentSpeed);
Assert.Equal(0.6, copy.ActiveMoveSpeed); // class None (no table in tests): restored from the wire
Assert.Equal(1.2, copy.PassiveMoveSpeed);
Assert.Equal(100, copy.PhysicalDamage);
Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
Assert.Equal(1, copy.ControlSlots);
Assert.NotNull(copy.Owners);
Assert.Empty(copy.Owners);
}
[Fact]
public void BackToBackSaves_AreByteIdentical()
{
var bc = NewCreature();
bc.SetDamage(5, 10);
bc.PhysicalResistanceSeed = 25;
Assert.Equal(Snapshot(bc), Snapshot(bc));
}
[Fact]
public void PopulatedCreature_RoundTrips()
{
var bc = NewCreature();
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
World.AddEntity(master); // ReadEntity resolves the reference through the world table
_created.Add(master);
bc.Tamable = true;
bc.MinTameSkill = 47.1;
bc.SetControlMaster(master);
bc.Owners.Add(master);
bc.ControlOrder = OrderType.Guard;
bc.SetDamage(11, 17);
bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table
bc.SetMoveSpeed(0.25, 0.5);
bc.PhysicalResistanceSeed = 40;
bc.EnergyResistSeed = 15;
bc.FireDamage = 25;
bc.PhysicalDamage = 75;
bc.HitsMaxSeed = 250;
bc.Loyalty = 55;
bc.Home = new Point3D(1000, 1100, 5);
bc.RangeHome = 4;
bc.Team = 3;
bc.IsBonded = true;
bc.BondingBegin = Core.Now;
bc.RemoveIfUntamed = true;
bc.RemoveStep = 2;
bc.CorpseNameOverride = "a test corpse";
var copy = Load(Snapshot(bc));
Assert.True(copy.Controlled);
Assert.Equal(master, copy.ControlMaster);
Assert.Equal(OrderType.Guard, copy.ControlOrder);
Assert.True(copy.Tamable);
Assert.Equal(47.1, copy.MinTameSkill);
Assert.Equal(11, copy.DamageMin);
Assert.Equal(17, copy.DamageMax);
Assert.Equal(0.2, copy.ActiveSpeed);
Assert.Equal(0.4, copy.PassiveSpeed);
Assert.Equal(0.25, copy.ActiveMoveSpeed);
Assert.Equal(0.5, copy.PassiveMoveSpeed);
Assert.Equal(40, copy.PhysicalResistanceSeed);
Assert.Equal(15, copy.EnergyResistSeed);
Assert.Equal(25, copy.FireDamage);
Assert.Equal(75, copy.PhysicalDamage);
Assert.Equal(250, copy.HitsMaxSeed);
Assert.Equal(55, copy.Loyalty);
Assert.Equal(new Point3D(1000, 1100, 5), copy.Home);
Assert.Equal(4, copy.RangeHome);
Assert.Equal(3, copy.Team);
Assert.True(copy.IsBonded);
Assert.Equal(bc.BondingBegin, copy.BondingBegin);
Assert.True(copy.RemoveIfUntamed);
Assert.Equal(2, copy.RemoveStep);
Assert.Equal("a test corpse", copy.CorpseNameOverride);
Assert.Equal(master, copy.LastOwner);
}
[Fact]
public void UncontrolledSummon_KeepsItsSummonMaster()
{
var bc = NewCreature();
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
World.AddEntity(master);
_created.Add(master);
// Energy vortex-style: summoned with a master, never controlled.
bc.Summoned = true;
bc.SummonMaster = master;
var copy = Load(Snapshot(bc));
Assert.True(copy.Summoned);
Assert.False(copy.Controlled);
Assert.Equal(master, copy.SummonMaster);
Assert.Null(copy.ControlMaster);
}
private sealed class BucketStub : BaseCreature
{
public BucketStub() : base(AIType.AI_Melee) => Body = 0xC9;
public BucketStub(Serial serial) : base(serial) => Body = 0xC9;
public override SpeedLevel DefaultSpeedClass => SpeedLevel.Fast;
}
[Fact]
public void SpeedClass_Assignment_AppliesBucket_AndRoundTrips()
{
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
});
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.VeryFast, ActiveSpeed = 0.125, PassiveSpeed = 0.3,
ActiveMoveSpeed = 0.125, PassiveMoveSpeed = 0.6, Types = new HashSet<Type>()
});
var bc = new BucketStub();
_created.Add(bc);
Assert.Equal(0.2, bc.ActiveSpeed); // seeded from the default bucket
Assert.Equal(0.3, bc.ActiveMoveSpeed);
bc.SpeedClass = SpeedLevel.VeryFast; // boss state change
Assert.Equal(SpeedLevel.VeryFast, bc.SpeedClass); // conforming assignment holds
Assert.Equal(0.125, bc.ActiveSpeed);
Assert.Equal(0.3, bc.PassiveSpeed);
Assert.Equal(0.125, bc.ActiveMoveSpeed);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
Assert.Equal(0.3, bc.CurrentSpeed); // stayed in the passive mode
// The changed bucket persists; the (bucket-matching) speeds elide but restore
// through the new bucket - the consistency the stateful SpeedClass guarantees.
var writer = new BufferWriter(true);
bc.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new BucketStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(SpeedLevel.VeryFast, copy.SpeedClass);
Assert.Equal(0.125, copy.ActiveSpeed);
Assert.Equal(0.3, copy.PassiveSpeed);
Assert.Equal(0.125, copy.ActiveMoveSpeed);
Assert.Equal(0.6, copy.PassiveMoveSpeed);
}
[Fact]
public void PartialSpeedTuning_MakesTheCreatureFullyCustom()
{
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
});
var bc = new BucketStub();
_created.Add(bc);
bc.ActiveSpeed = 0.25; // one tuned value customizes the whole block
Assert.Equal(SpeedLevel.None, bc.SpeedClass); // the bucket label never lies
var writer = new BufferWriter(true);
bc.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new BucketStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
// All four persisted raw - no value is left silently tracking the table.
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(SpeedLevel.None, copy.SpeedClass);
Assert.Equal(0.25, copy.ActiveSpeed);
Assert.Equal(0.4, copy.PassiveSpeed);
Assert.Equal(0.3, copy.ActiveMoveSpeed);
Assert.Equal(0.9, copy.PassiveMoveSpeed);
}
}

View file

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

@ -121,7 +121,7 @@ public class BloodOathSpellTests
BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5)); BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5));
BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side CreatureEvents.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
Assert.Null(BloodOathSpell.GetBloodOath(target)); Assert.Null(BloodOathSpell.GetBloodOath(target));
Assert.False(BloodOathSpell.RemoveCurse(caster)); Assert.False(BloodOathSpell.RemoveCurse(caster));

View file

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

View file

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

View file

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

View file

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

View file

@ -227,12 +227,6 @@ public partial class BallOfSummoning : Item, TranslocationItem
if (pet.IsStabled) if (pet.IsStabled)
{ {
pet.SetControlMaster(from); pet.SetControlMaster(from);
if (pet.Summoned)
{
pet.SummonMaster = from;
}
pet.ControlTarget = from; pet.ControlTarget = from;
pet.ControlOrder = OrderType.Follow; pet.ControlOrder = OrderType.Follow;

View file

@ -0,0 +1,471 @@
{
"version": 23,
"type": "Server.Mobiles.BaseCreature",
"properties": [
{
"name": "DefaultAI",
"type": "Server.Mobiles.AIType",
"rule": "EnumMigrationRule"
},
{
"name": "CurrentAI",
"type": "Server.Mobiles.AIType",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "RangePerception",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "RangeFight",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "RangeHome",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "Team",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "FightMode",
"type": "Server.Mobiles.FightMode",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "SpeedClass",
"type": "Server.Mobiles.SpeedLevel",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "ActiveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "PassiveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "CurrentSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "ActiveMoveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "PassiveMoveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Home",
"type": "Server.Point3D",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Point3D"
]
},
{
"name": "HomeMap",
"type": "Server.Map",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Map"
]
},
{
"name": "Controlled",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "ControlTarget",
"type": "Server.Mobile",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "ControlDest",
"type": "Server.Point3D",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Point3D"
]
},
{
"name": "ControlOrder",
"type": "Server.Mobiles.OrderType",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "MinTameSkill",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Tamable",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Summoned",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "SummonEnd",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"AnchoredTime"
]
},
{
"name": "Master",
"type": "Server.Mobile",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "ControlSlots",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "Loyalty",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "CurrentWayPoint",
"type": "Server.Items.WayPoint",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "HitsMaxSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "StamMaxSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "ManaMaxSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "DamageMin",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "DamageMax",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PhysicalResistanceSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "FireResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "ColdResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PoisonResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "EnergyResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PhysicalDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "FireDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "ColdDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PoisonDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "EnergyDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "Owners",
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
"usesSaveFlag": true,
"rule": "ListMigrationRule",
"ruleArguments": [
"@Tidy",
"Server.Mobile",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "IsDeadPet",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "IsBonded",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "BondingBegin",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "OwnerAbandonTime",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "HasGeneratedLoot",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "IsParagon",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Friends",
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
"usesSaveFlag": true,
"rule": "ListMigrationRule",
"ruleArguments": [
"@Tidy",
"Server.Mobile",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "RemoveIfUntamed",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "RemoveStep",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PendingDeleteTimer",
"type": "Server.Timer",
"usesSaveFlag": true,
"rule": "TimerMigrationRule",
"ruleArguments": [
"@AnchoredTimer"
]
},
{
"name": "CorpseNameOverride",
"type": "string",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
}
]
}

View file

@ -38,7 +38,7 @@ public class AnimalAI : BaseAI
return true; return true;
} }
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
{ {
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) 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)) 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} and I will attack");
Mobile.Combatant = Mobile.FocusMob; Mobile.Combatant = Mobile.FocusMob;
Action = ActionType.Combat; Action = ActionType.Combat;
@ -43,7 +43,7 @@ public class ArcherAI : BaseAI
return true; return true;
} }
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange))
{ {
this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); this.DebugSayFormatted($"I am still not in range of {combatant.Name}");

View file

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

View file

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

View file

@ -26,13 +26,11 @@ public sealed class AITimer : Timer
{ {
private readonly BaseAI _owner; private readonly BaseAI _owner;
private long _nextThink; private long _nextThink;
private long _nextWake; // when the pending wheel entry fires
private bool _inTick;
private int _detectHiddenMinDelay; private int _detectHiddenMinDelay;
private int _detectHiddenMaxDelay; private int _detectHiddenMaxDelay;
// The initial delay is irrelevant: Activate is the only start path and sets its own. public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)),
public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
{ {
_owner = owner; _owner = owner;
_owner._nextDetectHidden = Core.TickCount; _owner._nextDetectHidden = Core.TickCount;
@ -42,34 +40,8 @@ public sealed class AITimer : Timer
public void Activate() public void Activate()
{ {
_nextThink = Core.TickCount; _nextThink = Core.TickCount;
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
if (Running)
{
return;
}
// 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(); 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. // A speed-up must not wait out a stale, longer think deadline.
@ -80,53 +52,12 @@ public sealed class AITimer : Timer
if (candidate - _nextThink < 0) if (candidate - _nextThink < 0)
{ {
_nextThink = candidate; _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
} }
var now = Core.TickCount; Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
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() protected override void OnTick()
{
_inTick = true;
try
{
OnTickCore();
}
finally
{
_inTick = false;
}
}
private void OnTickCore()
{ {
if (ShouldStop()) if (ShouldStop())
{ {
@ -152,18 +83,7 @@ public sealed class AITimer : Timer
} }
// Cadence from the post-decision speed (decisions may flip active/passive). // Cadence from the post-decision speed (decisions may flip active/passive).
var period = (long)(_owner.Mobile.CurrentSpeed * 1000); _nextThink = Core.TickCount + (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 else
{ {
@ -191,7 +111,6 @@ public sealed class AITimer : Timer
// The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn.
Interval = TimeSpan.FromMilliseconds(delay); Interval = TimeSpan.FromMilliseconds(delay);
_nextWake = now + (long)Interval.TotalMilliseconds;
} }
private bool ShouldStop() private bool ShouldStop()

View file

@ -64,7 +64,7 @@ public abstract partial class BaseAI
if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active)
{ {
AITimer.Activate(); AITimer.Start();
} }
if (Action != ActionType.Wander) if (Action != ActionType.Wander)
@ -442,7 +442,7 @@ public abstract partial class BaseAI
var master = Mobile.SummonMaster; var master = Mobile.SummonMaster;
if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception))
{ {
MoveTo(master, 1); MoveTo(master, false, 1);
} }
} }
@ -592,7 +592,7 @@ public abstract partial class BaseAI
} }
_lkpGoal ??= _lkpLocation; _lkpGoal ??= _lkpLocation;
return MoveToPoint(_lkpGoal); return MoveToPoint(_lkpGoal, false);
} }
private void ClearLastKnown() 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); _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
} }
MoveToPoint(_herdGoal); MoveToPoint(_herdGoal, false);
return true; return true;
} }
@ -798,7 +798,7 @@ public abstract partial class BaseAI
{ {
if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
{ {
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
{ {
DebugSay("I backed off to safety. Wandering..."); DebugSay("I backed off to safety. Wandering...");
@ -850,24 +850,22 @@ public abstract partial class BaseAI
return false; return false;
} }
var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; if (Core.TickCount - Mobile.NextReacquireTime < 0)
var gateRemaining = Mobile.NextReacquireTime - Core.TickCount;
if (gateRemaining > 0 && gateRemaining <= reacquireDelay)
{ {
Mobile.FocusMob = null; Mobile.FocusMob = null;
return false; return false;
} }
DebugSay("Acquiring new target...", 0); Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds;
var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); DebugSay("Acquiring new target...");
// Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach), if (Mobile.Map == null)
// not this poll — every scan honors the full delay. {
Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; return Mobile.FocusMob != null;
}
return acquired; return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
} }
private bool HandleBardProvoked() private bool HandleBardProvoked()
@ -943,10 +941,8 @@ public abstract partial class BaseAI
private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe) private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe)
{ {
Mobile newFocusMob = null; Mobile newFocusMob = null, enemySummonMob = null;
Mobile enemySummonMob = null; double val = double.MinValue, enemySummonVal = double.MinValue;
var val = double.MinValue;
var enemySummonVal = double.MinValue;
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
{ {

View file

@ -447,11 +447,6 @@ public abstract partial class BaseAI
if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey")) if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey"))
{ {
Mobile.SetControlMaster(e.Mobile); Mobile.SetControlMaster(e.Mobile);
if (Mobile.Summoned)
{
Mobile.SummonMaster = e.Mobile;
}
} }
} }
} }

View file

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

View file

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

View file

@ -156,11 +156,6 @@ internal sealed partial class TransferItem : Item
private void TransferPetOwnership(Mobile from, Mobile to) private void TransferPetOwnership(Mobile from, Mobile to)
{ {
if (_creature.Summoned)
{
_creature.SummonMaster = to;
}
_creature.ControlTarget = to; _creature.ControlTarget = to;
_creature.ControlOrder = OrderType.Follow; _creature.ControlOrder = OrderType.Follow;
_creature.BondingBegin = DateTime.MinValue; _creature.BondingBegin = DateTime.MinValue;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -99,8 +99,8 @@ public abstract class MonsterAbility
{ {
} }
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))] [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
public static void InvalidateNextAbilityTriggers(BaseCreature source) public static void InvalidateNextAbilityTriggers(BaseCreature source)
{ {
var abilities = source.GetMonsterAbilities(); var abilities = source.GetMonsterAbilities();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
using ModernUO.CodeGeneratedEvents;
namespace Server.Mobiles;
// Hosts BaseCreature's generated events. They cannot live on BaseCreature itself: the
// events generator and the serialization generator each emit a [GeneratedCode] partial for
// the declaring type, and the attribute does not allow duplicates (CS0579).
public static partial class CreatureEvents
{
[GeneratedEvent(nameof(CreatureDeathEvent))]
public static partial void CreatureDeathEvent(BaseCreature bc);
[GeneratedEvent(nameof(CreatureDeletedEvent))]
public static partial void CreatureDeletedEvent(BaseCreature bc);
}

View file

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

View file

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

View file

@ -154,7 +154,7 @@ namespace Server.Mobiles
public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m); public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m);
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(CreatureDeathEvent))] [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
public static void StopEffect(Mobile m, bool message = false) public static void StopEffect(Mobile m, bool message = false)
{ {
if (m_Table.Remove(m, out var timer)) if (m_Table.Remove(m, out var timer))

View file

@ -8,7 +8,7 @@ namespace Server.Mobiles;
public enum SpeedLevel public enum SpeedLevel
{ {
None, None, // no bucket: the creature's own speeds are authoritative (custom)
VerySlow, VerySlow,
Slow, Slow,
Medium, Medium,
@ -26,34 +26,29 @@ public static class NPCSpeeds
public static int MinIdleSeconds { get; private set; } public static int MinIdleSeconds { get; private set; }
public static int MaxIdleSeconds { get; private set; } public static int MaxIdleSeconds { get; private set; }
public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed) // Construction-time resolution of a type's bucket: an explicit DefaultSpeedClass,
// else the table's type list, else Medium so unconfigured creatures never construct
// at 0/0. None only when the table itself is unloaded (test fixtures).
public static SpeedLevel ResolveDefaultLevel(BaseCreature bc)
{ {
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && if (bc.DefaultSpeedClass != SpeedLevel.None)
!_speedsByType.TryGetValue(bc.GetType(), out sp))
{ {
sp = _speedsByLevel[SpeedLevel.Medium]; return bc.DefaultSpeedClass;
} }
activeSpeed = sp.ActiveSpeed; if (_speedsByType.TryGetValue(bc.GetType(), out var sp))
passiveSpeed = sp.PassiveSpeed;
}
// Move speeds are optional (0 = inherit), so this tolerates a missing entry or table.
public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed)
{
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
!_speedsByType.TryGetValue(bc.GetType(), out sp) &&
!_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp))
{ {
activeMoveSpeed = 0; return sp.Level;
passiveMoveSpeed = 0;
return;
} }
activeMoveSpeed = sp.ActiveMoveSpeed; return _speedsByLevel.ContainsKey(SpeedLevel.Medium) ? SpeedLevel.Medium : SpeedLevel.None;
passiveMoveSpeed = sp.PassiveMoveSpeed;
} }
// Null for None (custom) or an unloaded table. Creatures cache the result — the
// table is immutable after Configure.
public static SpeedClassEntry FindEntry(SpeedLevel level) =>
level == SpeedLevel.None ? null : _speedsByLevel.GetValueOrDefault(level);
public static void RegisterSpeed(SpeedClassEntry entry) public static void RegisterSpeed(SpeedClassEntry entry)
{ {
_speedsByLevel[entry.Level] = entry; _speedsByLevel[entry.Level] = entry;

View file

@ -3553,7 +3553,6 @@ namespace Server.Mobiles
pet.Internalize(); pet.Internalize();
pet.SetControlMaster(null); pet.SetControlMaster(null);
pet.SummonMaster = null;
pet.IsStabled = true; pet.IsStabled = true;
pet.StabledBy = this; pet.StabledBy = this;
@ -3601,12 +3600,6 @@ namespace Server.Mobiles
if (Followers + pet.ControlSlots <= FollowersMax) if (Followers + pet.ControlSlots <= FollowersMax)
{ {
pet.SetControlMaster(this); pet.SetControlMaster(this);
if (pet.Summoned)
{
pet.SummonMaster = this;
}
pet.ControlTarget = this; pet.ControlTarget = this;
pet.ControlOrder = OrderType.Follow; pet.ControlOrder = OrderType.Follow;

View file

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

View file

@ -255,7 +255,6 @@ namespace Server.Mobiles
pet.Internalize(); pet.Internalize();
pet.SetControlMaster(null); pet.SetControlMaster(null);
pet.SummonMaster = null;
pet.IsStabled = true; pet.IsStabled = true;
pet.StabledBy = from; pet.StabledBy = from;
@ -356,12 +355,6 @@ namespace Server.Mobiles
private void DoClaim(Mobile from, BaseCreature pet) private void DoClaim(Mobile from, BaseCreature pet)
{ {
pet.SetControlMaster(from); pet.SetControlMaster(from);
if (pet.Summoned)
{
pet.SummonMaster = from;
}
pet.ControlTarget = from; pet.ControlTarget = from;
pet.ControlOrder = OrderType.Follow; pet.ControlOrder = OrderType.Follow;

View file

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

View file

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

View file

@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell<Mobile>
// shared timer from either the caster or the target key, so a single call per mobile is enough. // shared timer from either the caster or the target key, so a single call per mobile is enough.
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))] [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
public static void OnCurseEnds(Mobile m) => RemoveCurse(m); public static void OnCurseEnds(Mobile m) => RemoveCurse(m);
private class ExpireTimer : Timer private class ExpireTimer : Timer

View file

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

View file

@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Beneficial); Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Beneficial);
} }
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))] [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
public static void OnDeathEvent(Mobile m) public static void OnDeathEvent(Mobile m)
{ {

View file

@ -29,9 +29,6 @@ description: >
- `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default) - `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default)
- `Name = "text"` -> `public override string DefaultName => "text";` - `Name = "text"` -> `public override string DefaultName => "text";`
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` - 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 ## Anti-Patterns
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)

View file

@ -27,18 +27,8 @@ description: >
(`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move
(`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is AND clears move overrides, `SetMoveSpeed()` sets move only -- see
derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- `dev-docs/content-patterns.md` § Creature Speeds
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 ## New Item Template

View file

@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs
(combat decisions, target acquisition, spell timing). (combat decisions, target acquisition, spell timing).
- **Move clock**`ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per - **Move clock**`ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per
step. Inherits the matching think value until overridden, so a creature configured with step. Inherits the matching think value until overridden, so a creature configured with
only think speeds behaves as one clock. Any value is legal — steps are scheduled only think speeds behaves as one clock. The properties read the raw override (`0` =
independently of think ticks, so the two need not divide evenly. inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are
scheduled independently of think ticks, so the two need not divide evenly.
Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type
lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code:
@ -283,91 +284,6 @@ ClearMoveSpeed(); // back to inheriting the think clock
All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
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 ## New Spell

View file

@ -474,88 +474,6 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha
| `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` | | `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` |
| `get { return value; }` | `=> value;` expression-bodied | | `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 ## Item Name Changes
```csharp ```csharp

View file

@ -130,13 +130,6 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
| `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same | | `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same |
| `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` | | `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` |
| `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters | | `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 ## Networking