diff --git a/.gitignore b/.gitignore index d29ee313a..d5b26e268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Distribution Files -/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO diff --git a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs deleted file mode 100644 index 9c3cdfd14..000000000 --- a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs +++ /dev/null @@ -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 Damagers(Mobile victim) - { - var result = new List(); - 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(); - } - } -} diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index d1db8113b..fb028b9d7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text); public delegate void PromptStateCallback(Mobile from, string text, T state); -public class DamageEntry : IValueLinkListNode +public class DamageEntry { public DamageEntry(Mobile damager) => Damager = damager; @@ -57,11 +57,6 @@ public class DamageEntry : IValueLinkListNode public List Responsible { get; set; } public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0); - - // Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list. - public DamageEntry Next { get; set; } - public DamageEntry Previous { get; set; } - public bool OnLinkList { get; set; } } [Flags] @@ -382,6 +377,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; + DamageEntries = new List(); } // Sectors @@ -962,23 +958,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static VisibleDamageType VisibleDamageType { get; set; } - private ValueLinkList _damageEntries; - - /// - /// Damage entries ordered least recent (head) to most recent (tail). Expired entries are - /// pruned on access. Enumerate with foreach (ascending) or .ByDescending(). - /// Mutate only through and . - /// 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. - /// - public ref readonly ValueLinkList DamageEntries - { - get - { - PruneExpiredDamageEntries(); - return ref _damageEntries; - } - } + public List DamageEntries { get; private set; } [CommandProperty(AccessLevel.GameMaster)] public Mobile LastKiller { get; set; } @@ -1647,7 +1627,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player; - public bool HasTrade => m_NetState?.Trades?.Count > 0; + public bool HasTrade => m_NetState?.Trades.Count > 0; public bool NoMoveHS { get; set; } @@ -2040,7 +2020,10 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro 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) { @@ -5762,54 +5745,24 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } - // Entries are kept in LastDamage order, so expired entries are always a head prefix. - private void PruneExpiredDamageEntries() - { -#if DEBUG - for (var node = _damageEntries._first; node != null; node = node.Next) - { - Debug.Assert( - node.Next == null || node.Next.LastDamage >= node.LastDamage, - "Damage entries must be ordered by LastDamage ascending." - ); - } -#endif - - var first = _damageEntries._first; - - if (first?.HasExpired != true) - { - return; - } - - var firstLive = first.Next; - - while (firstLive?.HasExpired == true) - { - firstLive = firstLive.Next; - } - - if (firstLive == null) - { - _damageEntries.RemoveAll(); - } - else - { - _damageEntries.RemoveAllBefore(firstLive); - } - } - - public void ClearDamageEntries() => _damageEntries.RemoveAll(); - public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager; public DamageEntry FindMostRecentDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - 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; } @@ -5822,11 +5775,21 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._first; de != null; de = de.Next) + for (var i = 0; i < DamageEntries.Count; ++i) { - 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; } @@ -5837,17 +5800,24 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; - // Walks most recent first with a strict comparison so the most recent entry wins ties, - // matching the previous reverse-indexed loop. public DamageEntry FindMostTotalDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - DamageEntry mostTotal = null; - for (var 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; } @@ -5860,28 +5830,46 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); + DamageEntry mostTotal = null; - DamageEntry leastTotal = 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) && (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) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - 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; } @@ -5899,13 +5887,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro de.DamageGiven += amount; de.LastDamage = Core.Now; - // Move to the tail so the list stays in LastDamage order. - if (de.OnLinkList) - { - _damageEntries.Remove(de); - } - - _damageEntries.AddLast(de); + DamageEntries.Remove(de); + DamageEntries.Add(de); var master = from.GetDamageMaster(this); @@ -6495,6 +6478,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_DexLock = (StatLockType)reader.ReadByte(); m_IntLock = (StatLockType)reader.ReadByte(); + _statMods = new List(); + _skillMods = new List(); + if (version < 32) { if (reader.ReadBool()) @@ -7827,10 +7813,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_FollowersMax = 5; Skills = new Skills(this); Items = new List(); + _statMods = new List(); + _skillMods = new List(); Map = Map.Internal; AutoPageNotify = true; Aggressors = new List(); Aggressed = new List(); + DamageEntries = new List(); NextSkillTime = Core.TickCount; } diff --git a/Projects/Server/Network/MovementThrottle.cs b/Projects/Server/Network/MovementThrottle.cs index 33ee318fc..c28ef2228 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -50,6 +50,9 @@ public static class MovementThrottle private const int ClientMaxUnackedMovements = 5; private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 + // Debug logging - enable for testing speed hack detection + private static bool _debugLogging = false; + // Track NetStates with queued movements for efficient processing private static readonly HashSet _netStatesWithQueuedMovements = new(256); @@ -80,9 +83,15 @@ public static class MovementThrottle public static void Configure() { - _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); - _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); - _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); + _maxCredit = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.maxCredit", + _maxCredit + ); + + _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.hardQueueLimit", + _hardQueueLimit + ); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", @@ -94,13 +103,6 @@ public static class MovementThrottle _minSamplesForRate ); - _maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap); - - _speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.speedHackNotificationCooldown", - _speedHackNotificationCooldown - ); - _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold @@ -110,6 +112,11 @@ public static class MovementThrottle "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); + + _debugLogging = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.debugLogging", + _debugLogging + ); } /// @@ -184,16 +191,15 @@ public static class MovementThrottle // Credit can go negative up to -dynamicCredit (debt limit) if (ns._movementCredit - earlyAmount >= -dynamicCredit) { + var prevCredit = ns._movementCredit; // Use credit to cover early arrival ns._movementCredit -= earlyAmount; - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { - var prevCredit = ns._movementCredit + earlyAmount; - logger.Debug( "[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; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[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; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); - if (ns._movementLogging && ns._movementCredit != prevCredit) + if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[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 (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.) @@ -251,11 +260,11 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[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; _netStatesWithQueuedMovements.Add(ns); - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[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.ResetMovementState(); + _netStatesWithQueuedMovements.Remove(ns); } /// @@ -323,18 +333,20 @@ public static class MovementThrottle return; } - foreach (var ns in _netStatesWithQueuedMovements) + // Process each NetState with queued movements + // Use a snapshot to avoid modification during iteration + var toProcess = new List(_netStatesWithQueuedMovements); + + for (var i = 0; i < toProcess.Count; i++) { - if (ns.Running) + var ns = toProcess[i]; + if (!ns.Running) { - ProcessMovementQueue(ns); - if (ns._hasQueuedMovements) - { - continue; - } + _netStatesWithQueuedMovements.Remove(ns); + continue; } - _netStatesWithQueuedMovements.Remove(ns); + ProcessMovementQueue(ns); } } @@ -344,7 +356,6 @@ public static class MovementThrottle public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; - if (mobile?.Deleted != false) { ClearQueue(ns); @@ -363,7 +374,7 @@ public static class MovementThrottle while (ns._movementQueue?.Count > 0) { // 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 break; @@ -383,11 +394,11 @@ public static class MovementThrottle // Execute the move if (!mobile.Move(movement.Direction)) { - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[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; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[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 ns._hasQueuedMovements = ns._movementQueue?.Count > 0; + if (!ns._hasQueuedMovements) + { + _netStatesWithQueuedMovements.Remove(ns); + } } /// @@ -454,6 +469,7 @@ public static class MovementThrottle { ns._movementQueue?.Clear(); ns._hasQueuedMovements = false; + _netStatesWithQueuedMovements.Remove(ns); } // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) @@ -468,7 +484,7 @@ public static class MovementThrottle logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", - mobile, + mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address @@ -500,7 +516,7 @@ public static class MovementThrottle private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) { // Calculate interval since last movement - var interval = ns._hasMovementRecord + var interval = ns._lastMovementRecordTime > 0 ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) @@ -509,7 +525,6 @@ public static class MovementThrottle if (interval <= 0 || interval > _maxChainGap) { ns._lastMovementRecordTime = now; - ns._hasMovementRecord = true; // Use RTT to distinguish "stopped moving" vs "lagged" // - Stable low-latency connection with gap >> RTT → player stopped, reset history @@ -529,19 +544,19 @@ public static class MovementThrottle // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { var action = shouldReset ? "history reset" : "history preserved (possible lag)"; logger.Debug( "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "RTT={RTT}ms stable={Stable} → {Action})", - mobile, 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; @@ -557,9 +572,12 @@ public static class MovementThrottle // the next real move's interval artificially short, inflating rate. 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; } @@ -595,16 +613,15 @@ public static class MovementThrottle } ns._lastMovementRecordTime = now; - ns._hasMovementRecord = true; // Debug logging - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; logger.Debug( "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", - mobile, interval, cost, record.QueueDepth, + mobile.RawName, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } @@ -797,7 +814,7 @@ public static class MovementThrottle var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging - if (ns._movementLogging) + if (_debugLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); @@ -960,19 +977,19 @@ public static class MovementThrottle var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging - if (ns._movementLogging) + if (_debugLogging && ns.Mobile?.RawName != null) { var (burstSize, _) = DetectRecentBurst(ns); - var probeStatus = ns._rttProbePending ? "pending" : "idle"; + var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; var queueDepth = ns._movementQueue?.Count ?? 0; logger.Debug( "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", - ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds + ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ); logger.Debug( " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", - ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus + ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ); } @@ -1008,11 +1025,11 @@ public static class MovementThrottle if (shouldNotify) { - if (ns._movementLogging) + if (_debugLogging) { logger.Debug( "[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); @@ -1037,12 +1054,11 @@ public static class MovementThrottle var now = Core.TickCount; // Rate-limit notifications per player - if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) + if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } - ns._speedHackNotified = true; ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; @@ -1054,7 +1070,7 @@ public static class MovementThrottle "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", urgency, - mobile, + mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", rate, sampleCount, @@ -1122,7 +1138,7 @@ public static class MovementThrottle Verdict = verdict, Confidence = confidence, AverageRtt = ns.AverageRtt, - LastRtt = ns.LastRtt, + LastRtt = ns._lastRtt, RttVariance = ns._rttVariance, StableConnection = ns.HasStableConnection, RttSampleCount = ns._rttSampleCount, diff --git a/Projects/Server/Network/NetState/NetState.Movement.cs b/Projects/Server/Network/NetState/NetState.Movement.cs index c6f28fcd1..1cc79429e 100644 --- a/Projects/Server/Network/NetState/NetState.Movement.cs +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.InteropServices; using Server.Logging; @@ -69,24 +70,23 @@ public partial class NetState internal Queue _movementQueue; // Lazy initialized internal long _movementCredit; // Credit buffer for timing jitter 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() // Movement history for rate-based speed hack detection (lazy initialized) internal MovementRecord[] _movementHistory; // Circular buffer internal int _movementHistoryIndex; // Next write position (also serves as count until full) internal bool _movementHistoryFull; // True once buffer has wrapped - internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord) - internal bool _hasMovementRecord; // False until the first movement in a chain is seen + internal long _lastMovementRecordTime; // For calculating intervals // Detection state internal int _consecutiveHighRateSeconds; // Sustained detection counter - internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) - internal bool _speedHackNotified; // False until the first notification is sent + internal long _lastSpeedHackNotification; // Rate-limit notifications internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) // 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 _peakMovementRate; // Highest rate seen (packets/sec) @@ -100,9 +100,10 @@ public partial class NetState _nextMovementTime = Core.TickCount; _movementCredit = 0; _hasQueuedMovements = false; + _sustainedQueueDepth = 0; // Reset movement history - next movement starts a new chain - _hasMovementRecord = false; + _lastMovementRecordTime = 0; _movementHistoryIndex = 0; _movementHistoryFull = false; @@ -112,7 +113,7 @@ public partial class NetState _rttProbeInterval = RttProbeIntervalNormal; // Reset packet rate window - _movementWindowStart = Core.TickCount; + _movementWindowStart = 0; _movementsInWindow = 0; } @@ -164,19 +165,17 @@ public partial class NetState private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection // RTT state - internal bool _rttProbePending; // True while waiting for a probe response - internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) + internal long _rttProbeTime; // When we sent the probe (0 = not waiting) + internal long _lastRtt; // Most recent RTT measurement internal long[] _rttHistory; // Rolling history (lazy init) internal int _rttHistoryIndex; // Current position in history internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize) internal long _rttVariance; // Calculated variance for stability - internal long _nextRttProbe = Core.TickCount; // When to send next probe + internal long _nextRttProbe; // When to send next probe internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval - /// - /// Gets the most recent RTT measurement, or 0 if none has been recorded. - /// - public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0; + // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) + private long _rttProbeTimestampHiRes; /// /// Sets the RTT probe interval based on suspicion level. @@ -207,22 +206,23 @@ public partial class NetState var now = Core.TickCount; // 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 if (now - _rttProbeTime > 10000) { - _rttProbePending = false; + _rttProbeTime = 0; + _rttProbeTimestampHiRes = 0; } return; } // First probe: send immediately when player starts moving // Subsequent probes: send when interval has passed - if (now - _nextRttProbe >= 0) + if (_nextRttProbe == 0 || now >= _nextRttProbe) { - _rttProbePending = true; _rttProbeTime = now; + _rttProbeTimestampHiRes = Stopwatch.GetTimestamp(); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); if (_movementLogging) @@ -242,9 +242,10 @@ public partial class NetState /// public void RecordRttMeasurement() { + var nowHiRes = Stopwatch.GetTimestamp(); var now = Core.TickCount; - if (!_rttProbePending) + if (_rttProbeTime <= 0) { // Not expecting a response (client-initiated version send) - ignore silently return; @@ -252,15 +253,19 @@ public partial class NetState var rtt = now - _rttProbeTime; + // High-resolution RTT in microseconds + var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency; + if (_movementLogging) { movementLogger.Debug( - "[RTT-Response] {Account}: {Rtt}ms", - Account?.Username ?? _toString, rtt + "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)", + Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 ); } - _rttProbePending = false; + _rttProbeTime = 0; + _rttProbeTimestampHiRes = 0; // Sanity check - RTT should be positive and reasonable if (rtt is <= 0 or > 10000) @@ -280,6 +285,7 @@ public partial class NetState // Update history _rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt; + _lastRtt = rtt; // Track sample count (saturates at buffer size) if (_rttSampleCount < RttHistorySize) diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 04f1a5a76..7312603f7 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -44,7 +44,7 @@ public partial class NetState : IComparable, IValueLinkListNode _connectingQueue = new(2048); private static readonly HashSet _instances = new(2048); - public static HashSet Instances => _instances; + public static IReadOnlySet Instances => _instances; private readonly string _toString; private ClientVersion _version; @@ -109,6 +109,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode Trades { get; private set; } + public List Trades { get; } public bool Seeded { get; set; } @@ -257,18 +260,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades == null) - { - break; - } - if (i >= Trades.Count) { continue; @@ -287,18 +280,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades != null) - { - break; - } - if (i < Trades.Count) { Trades[i].Cancel(); @@ -308,21 +291,11 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode _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); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index a952252a6..923f4aff9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -40,7 +40,7 @@ public class ApproachTargetTests for (var i = 0; i < maxTicks; i++) { ai.NextMove = 0; - ai.WalkMobileRange(target, 1, 1, 2); + ai.WalkMobileRange(target, 1, false, 1, 2); if (bc.InRange(target, arriveDist)) { return true; @@ -123,7 +123,7 @@ public class ApproachTargetTests for (var i = 0; i < 200; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, false, 1); if (bc.InRange(target, 1)) { arrived = true; @@ -154,7 +154,7 @@ public class ApproachTargetTests for (var i = 0; i < 60; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, true, 1); // Target walks west every other tick for its first several steps, then stops, // so a same-speed chaser eventually closes the gap. @@ -214,7 +214,7 @@ public class ApproachTargetTests for (var i = 0; i < 120; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, false, 1); } // After giving up, the creature must idle (not oscillate) while the goal is still. @@ -223,7 +223,7 @@ public class ApproachTargetTests for (var i = 0; i < 20; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, false, 1); if (bc.Location != idleStart) { stayedIdle = false; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs deleted file mode 100644 index db5225359..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ /dev/null @@ -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; - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs deleted file mode 100644 index 7d572786a..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ /dev/null @@ -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 _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); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs index 7d9243a29..7ad149eba 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable { var bc = NewCreature(); - Assert.Equal(0.3, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); + // 0 = no override; the resolved pace comes from CurrentMoveSpeed. + Assert.Equal(0, bc.ActiveMoveSpeed); + Assert.Equal(0, bc.PassiveMoveSpeed); Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); } @@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable bc.SetSpeed(0.2, 0.4); - Assert.Equal(0.2, bc.ActiveMoveSpeed); - Assert.Equal(0.4, bc.PassiveMoveSpeed); + Assert.Equal(0, bc.ActiveMoveSpeed); + Assert.Equal(0, bc.PassiveMoveSpeed); } [Fact] @@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable 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 + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock } [Fact] @@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable bc.ScaleMoveSpeed(1.0 / 1.2); 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] @@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable bc.MigrateMoveSpeeds(); - Assert.Equal(0.35, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock + Assert.Equal(0, bc.PassiveMoveSpeed); } [Theory] @@ -213,7 +216,7 @@ public class MoveSpeedTests : IDisposable // The v22 tail is the last block; exact consumption catches any offset mistake. Assert.Equal(buffer.Length, reader.Position); - Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); - Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); + Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed); + Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs deleted file mode 100644 index 217049b3d..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ /dev/null @@ -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 _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 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"); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs deleted file mode 100644 index a421aaba2..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs +++ /dev/null @@ -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 _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); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs new file mode 100644 index 000000000..724b14070 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -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 _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() + }); + NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry + { + Level = SpeedLevel.VeryFast, ActiveSpeed = 0.125, PassiveSpeed = 0.3, + ActiveMoveSpeed = 0.125, PassiveMoveSpeed = 0.6, Types = new HashSet() + }); + + 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() + }); + + 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); + } + +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs deleted file mode 100644 index e02abece7..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Collections.Generic; -using Server.Mobiles; -using Xunit; - -namespace Server.Tests; - -/// -/// 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. -/// -[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 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(); - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs index 0264a282e..08658b1a1 100644 --- a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs +++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs @@ -121,7 +121,7 @@ public class BloodOathSpellTests 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.False(BloodOathSpell.RemoveCurse(caster)); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 3a024576b..9daea0ba3 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -18,7 +18,6 @@ using System.Net; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; -using Server.Collections; using Server.Engines.Virtues; using Server.Gumps; using Server.Items; @@ -1182,6 +1181,11 @@ public partial class ChampionSpawn : Item foreach (var de in m.DamageEntries) { + if (de.HasExpired) + { + continue; + } + var damager = de.Damager; var master = damager.GetDamageMaster(m); diff --git a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs index f0fb2386f..32e8566cb 100644 --- a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs +++ b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs @@ -359,14 +359,14 @@ namespace Server.Factions { if (m_Mobile.InRange( m, 1 )) RunFrom( m ); - else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 )) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, 1)) + if (!MoveTo(m, true, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs index b67665d04..b82040134 100644 --- a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs +++ b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs @@ -219,7 +219,7 @@ namespace Server.Engines.Harvest } else { - bonusItem?.Delete(); + item.Delete(); } } diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 04aaf0148..3a0a56fa5 100644 --- a/Projects/UOContent/Engines/Pathing/PathFollower.cs +++ b/Projects/UOContent/Engines/Pathing/PathFollower.cs @@ -83,7 +83,7 @@ public class PathFollower public static bool Check(Point3D loc, Point3D goal, int range) => Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); - public bool Follow(int range) + public bool Follow(bool run, int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal); + d = m_From.GetDirectionTo(goal, run); m_From.SetDirection(d); return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - d = m_From.GetDirectionTo(m_Next); + d = m_From.GetDirectionTo(m_Next, run); m_From.SetDirection(d); var res = Move(d); diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index 070a802b1..3936d68db 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -227,12 +227,6 @@ public partial class BallOfSummoning : Item, TranslocationItem if (pet.IsStabled) { pet.SetControlMaster(from); - - if (pet.Summoned) - { - pet.SummonMaster = from; - } - pet.ControlTarget = from; pet.ControlOrder = OrderType.Follow; diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json new file mode 100644 index 000000000..077a428fe --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json @@ -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": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index e5a1670e3..a9d410483 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -38,7 +38,7 @@ public class AnimalAI : BaseAI 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) { diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index 803587ceb..dedd914c9 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -17,7 +17,7 @@ public class ArcherAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -43,7 +43,7 @@ public class ArcherAI : BaseAI 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}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 4463bf4de..548fe0d54 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -63,7 +63,7 @@ public abstract partial class BaseAI 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) { @@ -79,7 +79,7 @@ public abstract partial class BaseAI if (optimalPosition == Point3D.Zero) { - return ai.MoveToWithCollisionAvoidance(target, range); + return ai.MoveToWithCollisionAvoidance(target, run, range); } _reservedPositions[mobile] = optimalPosition; @@ -99,7 +99,7 @@ public abstract partial class BaseAI } // A blocked or wall-slid step is not progress — route around the obstacle. - return ai.ApproachTarget(target, range); + return ai.ApproachTarget(target, run, range); } finally { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 8b2a16cf2..ccd1c72b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -18,7 +18,6 @@ using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; using MoveImpl = Server.Movement.MovementImpl; -using Moves = Server.Movement.Movement; 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. private Mobile _moveIntentTarget; private IPoint3D _moveIntentPoint; + private bool _moveIntentRun; private int _moveIntentRange; private long _moveIntentExpire; @@ -74,42 +74,23 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Seconds per step as the client observes it: the move clock plus the hurt inflation. - private double EffectiveStepDelay() + // Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly + // regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step. + private void ConsumeMoveBudget() { var stepDelay = Mobile.CurrentMoveSpeed; - return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay); - } - - // The Running bit only selects the client's per-step interpolation (walk 400ms / run - // 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the - // client falls behind and snaps — but an isolated step (after standing at least a walk - // interval) renders alone and darts if run-flagged, so it goes out as a walk. A true - // sprinter always runs: a walk-rendered first step would flood the client's queue. - public bool ShouldRun() - { - var mounted = Mobile.Mounted || Mobile.Flying; - var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay; - var pace = EffectiveStepDelay() * 1000; - - if (pace >= walkDelay) + if (!(Core.AOS && IsFollowingMaster())) { - 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; - } - - // 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)); + if (Core.TickCount - NextMove > 0) + { + NextMove = Core.TickCount; + } } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -127,8 +108,6 @@ public abstract partial class BaseAI return MoveResult.BadState; } - d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0); - if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask)) { Mobile.Direction = d; @@ -139,17 +118,18 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Obeying pets are paced by their order handlers. - if (!IsObeyingMoveOrder()) + // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. + if (Core.AOS && IsFollowingMaster()) { - if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); - } + Mobile.CurrentSpeed = 0.1; + } + else if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); } ConsumeMoveBudget(); @@ -355,7 +335,7 @@ public abstract partial class BaseAI /// best-distance stall counter idles the creature if an in-range goal is genuinely /// unreachable, without ever abandoning a real chase or detour. /// - protected bool ApproachTarget(Mobile target, int range) + protected bool ApproachTarget(Mobile target, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { @@ -382,7 +362,7 @@ public abstract partial class BaseAI 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 // 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)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); if (res == MoveResult.BadState) { @@ -423,7 +403,7 @@ public abstract partial class BaseAI var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(range)) + if (Path.Follow(run, range)) { ResetApproach(); 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 /// obstacles. Returns false on arrival or when genuinely unable to make progress. /// - public bool MoveToPoint(IPoint3D goal) + public bool MoveToPoint(IPoint3D goal, bool run) { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { @@ -455,12 +435,12 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } - RenewMoveIntent(null, goal, 1); + RenewMoveIntent(null, goal, run, 1); var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(1)) + if (Path.Follow(run, 1)) { Path = null; ClearMoveIntent(); @@ -536,10 +516,11 @@ public abstract partial class BaseAI _approachGaveUp = false; } - private void RenewMoveIntent(Mobile target, IPoint3D point, int range) + private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) { _moveIntentTarget = target; _moveIntentPoint = point; + _moveIntentRun = run; _moveIntentRange = range; // A live pursuit renews every think tick; unrenewed intent dies on its own. @@ -560,7 +541,8 @@ public abstract partial class BaseAI { nextMove = NextMove; - return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; + return (_moveIntentTarget != null || _moveIntentPoint != null) && + Core.TickCount - _moveIntentExpire < 0; } /// @@ -576,21 +558,26 @@ public abstract partial class BaseAI if (_moveIntentTarget != null) { - ApproachTarget(_moveIntentTarget, _moveIntentRange); + ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); } 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) { return false; } + var distance = (int)Mobile.GetDistanceToSqrt(m); + var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5; + + var shouldRun = run && distance > distanceThreshold; + if (Mobile.InRange(m, range)) { ResetApproach(); @@ -599,10 +586,10 @@ public abstract partial class BaseAI 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)] @@ -612,15 +599,12 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; - // A pet executing a movement order outside combat; its order handler owns its speed. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsObeyingMoveOrder() => - Mobile.Controlled && - Mobile.Combatant == null && - Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; - - private bool MoveToWithCollisionAvoidance(Mobile target, int range) + private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) { + var distance = (int)Mobile.GetDistanceToSqrt(target); + + var shouldRun = run && distance > 5; + var direction = Mobile.GetDirectionTo(target); // Wall-slide auto-turns must not count as progress, or a creature pinned on @@ -651,10 +635,10 @@ public abstract partial class BaseAI // Tactical sidesteps exhausted — route around the obstacle via the centralized // approach primitive (persistent PathFollower, no oscillation). - return ApproachTarget(target, 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) { @@ -665,12 +649,14 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); + var shouldRun = run && iCurrDist > 5; + if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) { return false; } @@ -681,16 +667,18 @@ public abstract partial class BaseAI 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) { // 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). - if (DoMove(m.GetDirectionTo(Mobile), true)) + if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index e11268e87..fe24e5f6d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -26,13 +26,11 @@ public sealed class AITimer : Timer { private readonly BaseAI _owner; private long _nextThink; - private long _nextWake; // when the pending wheel entry fires - private bool _inTick; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; - // The initial delay is irrelevant: Activate is the only start path and sets its own. - public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) + public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), + TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; @@ -42,34 +40,8 @@ public sealed class AITimer : Timer public void Activate() { _nextThink = Core.TickCount; - - 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)); + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); 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. @@ -80,53 +52,12 @@ public sealed class AITimer : Timer if (candidate - _nextThink < 0) { _nextThink = candidate; - Reschedule(); - } - } - - // Moves the pending wake earlier. Interval is only read after the next fire, - // so this needs Stop, Delay = remaining, Start. - private void Reschedule() - { - if (_inTick || !Running) - { - return; // ScheduleNext handles it at tick end } - var now = Core.TickCount; - var deadline = _nextThink; - - if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) - { - deadline = nextMove; - } - - if (deadline - _nextWake >= 0) - { - return; // pending wake is already early enough - } - - Stop(); - Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); - Start(); - _nextWake = now + (long)Delay.TotalMilliseconds; + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); } protected override void OnTick() - { - _inTick = true; - - try - { - OnTickCore(); - } - finally - { - _inTick = false; - } - } - - private void OnTickCore() { if (ShouldStop()) { @@ -152,18 +83,7 @@ public sealed class AITimer : Timer } // Cadence from the post-decision speed (decisions may flip active/passive). - var period = (long)(_owner.Mobile.CurrentSpeed * 1000); - _nextThink = Core.TickCount + period; - - // Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so - // creatures spawned or woken together cannot stay in lock-step (a one-shot - // spread can collide and identical periods never separate). Engaged cadence - // stays exact — pursuit timing anchors to real step times. - if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed) - { - var jitter = (int)(period >> 3); - _nextThink += Utility.RandomMinMax(-jitter, jitter); - } + _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); } 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. Interval = TimeSpan.FromMilliseconds(delay); - _nextWake = now + (long)Interval.TotalMilliseconds; } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b8753d10..cf64cae17 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -64,7 +64,7 @@ public abstract partial class BaseAI if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - AITimer.Activate(); + AITimer.Start(); } if (Action != ActionType.Wander) @@ -442,7 +442,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, 1); + MoveTo(master, false, 1); } } @@ -592,7 +592,7 @@ public abstract partial class BaseAI } _lkpGoal ??= _lkpLocation; - return MoveToPoint(_lkpGoal); + return MoveToPoint(_lkpGoal, false); } private void ClearLastKnown() @@ -644,7 +644,7 @@ public abstract partial class BaseAI _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); } - MoveToPoint(_herdGoal); + MoveToPoint(_herdGoal, false); return true; } @@ -798,7 +798,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); @@ -850,24 +850,22 @@ public abstract partial class BaseAI return false; } - var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; - var gateRemaining = Mobile.NextReacquireTime - Core.TickCount; - - if (gateRemaining > 0 && gateRemaining <= reacquireDelay) + if (Core.TickCount - Mobile.NextReacquireTime < 0) { Mobile.FocusMob = null; 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), - // not this poll — every scan honors the full delay. - Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; + if (Mobile.Map == null) + { + return Mobile.FocusMob != null; + } - return acquired; + return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); } 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) { - Mobile newFocusMob = null; - Mobile enemySummonMob = null; - var val = double.MinValue; - var enemySummonVal = double.MinValue; + Mobile newFocusMob = null, enemySummonMob = null; + double val = double.MinValue, enemySummonVal = double.MinValue; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index 8ffd39b42..f254b68ae 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -447,11 +447,6 @@ public abstract partial class BaseAI if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey")) { Mobile.SetControlMaster(e.Mobile); - - if (Mobile.Summoned) - { - Mobile.SummonMaster = e.Mobile; - } } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 862c78649..ecb456d2e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,7 +26,7 @@ public abstract partial class BaseAI return; } - AITimer.Prod(); + Activate(); switch (Mobile.ControlOrder) { @@ -36,10 +36,6 @@ public abstract partial class BaseAI break; } case OrderType.Come: - { - Mobile.SetCurrentSpeedToActive(); - break; - } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -139,7 +135,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); } private void HandleTransferOrder() @@ -153,7 +148,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -168,16 +162,9 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - - // Resuming the persistent order must not replay the flourish. - if (!_resolvingOrder) - { - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. - } - + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. _commandIssuer = null; } @@ -204,7 +191,6 @@ public abstract partial class BaseAI } Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -220,7 +206,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -236,7 +221,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e6c48850d..1b7139a96 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -84,7 +84,7 @@ public abstract partial class BaseAI return true; } - WalkMobileRange(Mobile.ControlMaster, 1, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 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}."); - // AOS: sprint after the master (bespoke 0.1 paces both clocks). - if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) - { - Mobile.CurrentSpeed = 0.1; - } - if (currentDistance > 1) { - WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); } } @@ -297,13 +291,14 @@ public abstract partial class BaseAI return true; } - var combatant = FindGuardTarget(); + FindCombatant(); - if (combatant != null) + if (IsValidCombatant(Mobile.Combatant)) { + var combatant = Mobile.Combatant; + this.DebugSayFormatted($"Attacking target: {combatant.Name}"); - // Engage without leaving the Guard order so tags, recall handling, and retargeting persist. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -314,30 +309,16 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - // Stand down; a stale Warmode would skew the return pace. - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; + var guardLocation = controlMaster.Location; - var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); + var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); if (distance > 3) { - // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. - if (Core.AOS) - { - Mobile.CurrentSpeed = 0.1; - } - else - { - Mobile.SetCurrentSpeedToActive(); - } - - WalkMobileRange(controlMaster, 1, 1, 3); + DoMove(Mobile.GetDirectionTo(guardLocation)); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -378,83 +359,67 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. - if (Mobile.ControlOrder == OrderType.Guard || - Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) + if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) { - return; - } - - var next = FindGuardTarget(); - - if (next != null) - { - Mobile.ControlTarget = next; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = next; - - this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); - - Think(); + FindCombatant(); } } - /// - /// Selects the aggressor closest to the master. The current combatant is kept - /// unless a strictly closer one exists. Never mutates order state. - /// - private Mobile FindGuardTarget() + private void FindCombatant() { var controlMaster = Mobile.ControlMaster; - var anchor = controlMaster ?? Mobile; - - var current = Mobile.Combatant; - var best = current != controlMaster && IsValidCombatant(current) ? current : null; - var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (aggr == best || aggr == Mobile || aggr == controlMaster || - aggr.IsDeadBondedPet || !aggr.Alive || - aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) + if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) { continue; } - var dist = aggr.GetDistanceToSqrt(anchor); + var isAttackingPet = aggr.Combatant == Mobile; + var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; - if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) + if (isAttackingPet || isAttackingMaster) { - best = aggr; - bestDist = dist; + if (Mobile.InLOS(aggr)) + { + Mobile.ControlTarget = aggr; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggr; + + var target = isAttackingMaster ? "master" : "me"; + this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); + + Think(); + return; + } } } - var aggressors = controlMaster?.Aggressors; - - if (aggressors != null) + if (controlMaster?.Aggressors != null) { - for (var i = 0; i < aggressors.Count; i++) + for (var i = 0; i < controlMaster.Aggressors.Count; i++) { - var aggressor = aggressors[i].Attacker; + var aggressor = controlMaster.Aggressors[i].Attacker; - if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || - aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) + if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) { continue; } - var dist = aggressor.GetDistanceToSqrt(anchor); - - if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - best = aggressor; - bestDist = dist; + Mobile.ControlTarget = aggressor; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggressor; + + this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); + + Think(); + return; } } } - - return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 9efad5a5e..9f2fa937c 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -156,11 +156,6 @@ internal sealed partial class TransferItem : Item private void TransferPetOwnership(Mobile from, Mobile to) { - if (_creature.Summoned) - { - _creature.SummonMaster = to; - } - _creature.ControlTarget = to; _creature.ControlOrder = OrderType.Follow; _creature.BondingBegin = DateTime.MinValue; diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 8a2015f1a..4663ae8d0 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -12,7 +12,7 @@ public class BerserkAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI 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}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index cc54c73c3..2a1127b43 100644 --- a/Projects/UOContent/Mobiles/AI/HealerAI.cs +++ b/Projects/UOContent/Mobiles/AI/HealerAI.cs @@ -81,7 +81,7 @@ public class HealerAI : BaseAI return true; } - WalkMobileRange(Mobile.FocusMob, 1, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7); // TODO: Should it be able to do this? if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant)) diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 1ed8cbdda..61be59a1f 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -171,7 +171,7 @@ public class MageAI : BaseAI { if (!SmartAI) { - if (!MoveTo(m, Mobile.RangeFight)) + if (!MoveTo(m, false, Mobile.RangeFight)) { OnFailedMove(); } @@ -185,14 +185,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, 1)) + if (!MoveTo(m, false, 1)) { OnFailedMove(); } @@ -701,7 +701,7 @@ public class MageAI : BaseAI { DebugSay("I cannot see my target, moving to regain line of sight"); - if (!MoveTo(c, 1)) + if (!MoveTo(c, false, 1)) { OnFailedMove(); } @@ -1039,7 +1039,7 @@ public class MageAI : BaseAI // target can be invoked. if (!Mobile.InLOS(toTarget)) { - MoveTo(toTarget, 1); + MoveTo(toTarget, true, 1); } else { diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index a6d0a9bf9..544770069 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; - namespace Server.Mobiles; public class MeleeAI : BaseAI @@ -16,7 +14,6 @@ public class MeleeAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); - Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } @@ -68,9 +65,13 @@ public class MeleeAI : BaseAI return true; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsValidCombatant(Mobile combatant) => - combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet; + private bool IsValidCombatant(Mobile combatant) + { + return combatant?.Deleted == false + && combatant.Map == Mobile.Map + && combatant.Alive + && !combatant.IsDeadBondedPet; + } private bool HandleOutOfRangeCombatant(Mobile combatant) { @@ -98,7 +99,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, Mobile.RangeFight)) + if (MoveTo(combatant, false, Mobile.RangeFight)) { return true; } @@ -126,8 +127,7 @@ public class MeleeAI : BaseAI { if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); - + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking."); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index b1ea3a638..5e0e01520 100644 --- a/Projects/UOContent/Mobiles/AI/PredatorAI.cs +++ b/Projects/UOContent/Mobiles/AI/PredatorAI.cs @@ -41,7 +41,7 @@ public class PredatorAI : BaseAI 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) { @@ -70,7 +70,7 @@ public class PredatorAI : BaseAI } 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"); diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index a9209dfbb..0fa37bbc9 100644 --- a/Projects/UOContent/Mobiles/AI/ThiefAI.cs +++ b/Projects/UOContent/Mobiles/AI/ThiefAI.cs @@ -43,7 +43,7 @@ public class ThiefAI : BaseAI 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}"); } diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs index fc227507e..75c6fc3dc 100644 --- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs +++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs @@ -99,8 +99,8 @@ public abstract class MonsterAbility { } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void InvalidateNextAbilityTriggers(BaseCreature source) { var abilities = source.GetMonsterAbilities(); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 404aaede8..67da2b526 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using ModernUO.CodeGeneratedEvents; +using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; using Server.Engines.ConPVP; @@ -13,6 +13,7 @@ using Server.Engines.Virtues; using Server.Ethics; using Server.Factions; using Server.Items; +using Server.Logging; using Server.Misc; using Server.Multis; using Server.Network; @@ -133,8 +134,11 @@ namespace Server.Mobiles public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage); } + [SerializationGenerator(23, false)] public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature)); + public enum Allegiance { None, @@ -160,7 +164,7 @@ namespace Server.Mobiles public const int DefaultRangePerception = 16; - private const double ChanceToRummage = 0.5; // 50% + private const double ChanceToRummage = 0.5; private const double MinutesToNextRummageMin = 1.0; private const double MinutesToNextRummageMax = 4.0; @@ -227,7 +231,7 @@ namespace Server.Mobiles private static readonly Type[] _gold = { - // white wyrms eat gold.. + // White wyrms eat gold. typeof(Gold) }; @@ -250,54 +254,600 @@ namespace Server.Mobiles typeof(AncientSmithyHammer), typeof(Scorp) }; - private bool _summoned; + // --- Serialized state --------------------------------------------------------- + // Nearly every field is behind a [SaveFlag] so a creature that matches its + // defaults (including npc-speeds table values) writes only the version and flags. - private bool m_bTamable; - private int m_ColdResistance; + [SerializableField(0, setter: "private")] + private AIType _defaultAI; - private bool _controlled; // Is controlled - private Mobile m_ControlMaster; // My master - private OrderType m_ControlOrder; // My order + [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeCurrentAI), nameof(CurrentAIDefaultValue))] + private AIType _currentAI; - private AIType m_CurrentAI; // The current AI + private bool ShouldSerializeCurrentAI() => _currentAI != _defaultAI; + private AIType CurrentAIDefaultValue() => _defaultAI; + + [EncodedInt] + [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeRangePerception), nameof(RangePerceptionDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangePerception; + + private bool ShouldSerializeRangePerception() => _rangePerception != DefaultRangePerception; + + private int RangePerceptionDefaultValue() => DefaultRangePerception; + + [EncodedInt] + [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeRangeFight), nameof(RangeFightDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeFight; + + private bool ShouldSerializeRangeFight() => _rangeFight != 1; + + private int RangeFightDefaultValue() => 1; + + [EncodedInt] + [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeRangeHome), nameof(RangeHomeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeHome = 10; + + private bool ShouldSerializeRangeHome() => _rangeHome != 10; + + private int RangeHomeDefaultValue() => 10; + + [EncodedInt] + [SerializableField(5, fieldChanged: nameof(OnTeamChange))] + [SaveFlag(nameof(ShouldSerializeTeam))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _team; + + private bool ShouldSerializeTeam() => _team != 0; + + private void OnTeamChange(int oldValue, int newValue) => OnTeamChange(); + + [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeFightMode), nameof(FightModeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private FightMode _fightMode; + + private bool ShouldSerializeFightMode() => _fightMode != FightMode.Closest; + + private FightMode FightModeDefaultValue() => FightMode.Closest; + + /// + /// The creature's npc-speeds bucket, resolved at construction. Assigning applies + /// the bucket's speeds; None means custom (its own speeds are authoritative). A + /// type's constant bucket belongs in . + /// + [SerializableField(7, fieldChanged: nameof(OnSpeedClassChange))] + [SaveFlag(nameof(ShouldSerializeSpeedClass), nameof(SpeedClassDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private SpeedLevel _speedClass; + + private bool ShouldSerializeSpeedClass() => _speedClass != ResolvedDefaultSpeedClass; + + private SpeedLevel SpeedClassDefaultValue() => ResolvedDefaultSpeedClass; + + private void OnSpeedClassChange(SpeedLevel oldValue, SpeedLevel newValue) + { + _speedEntry = null; + ApplySpeedClass(); + } + + private bool _applyingSpeedClass; + + // Applies the current bucket's speeds, preserving the active/passive mode. The + // guard keeps OnSpeedTuned from reading the half-assigned block as customization. + private void ApplySpeedClass() + { + if (SpeedEntry == null) + { + return; // None (custom) or an unloaded table has no bucket to apply + } + + _applyingSpeedClass = true; + + try + { + var wasActive = _currentSpeed == _activeSpeed && _currentSpeed != _passiveSpeed; + + GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + + ActiveSpeed = activeSpeed; + PassiveSpeed = passiveSpeed; + CurrentSpeed = wasActive ? activeSpeed : passiveSpeed; + } + finally + { + _applyingSpeedClass = false; + } + } + + /// Seconds per AI decision while engaged; see for movement pace. + [SerializableField(8, fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(ActiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeSpeed; + + // The four speeds are one block: either all conform to the bucket (elided as a + // set), or the creature is custom and all four serialize. Partial conformance + // cannot exist on the wire. + private bool ShouldSerializeSpeeds() + { + if (_speedClass == SpeedLevel.None) + { + return true; + } + + GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); + + return _activeSpeed != activeSpeed || _passiveSpeed != passiveSpeed || + _activeMoveSpeed != activeMoveSpeed || _passiveMoveSpeed != passiveMoveSpeed; + } + + // Tuning any speed away from the bucket makes the creature fully custom - the + // bucket label must never lie. ApplySpeedClass assigns mid-transition and guards. + private void OnSpeedTuned(double oldValue, double newValue) + { + if (!_applyingSpeedClass && _speedClass != SpeedLevel.None && ShouldSerializeSpeeds()) + { + _speedClass = SpeedLevel.None; + _speedEntry = null; + } + } + + private double ActiveSpeedDefaultValue() + { + GetSpeeds(out var activeSpeed, out _); + return activeSpeed; + } + + /// Seconds per AI decision while idle; see for movement pace. + [SerializableField(9, fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(PassiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveSpeed; + + private double PassiveSpeedDefaultValue() + { + GetSpeeds(out _, out var passiveSpeed); + return passiveSpeed; + } + + [SerializableField(10, fieldChanged: nameof(OnCurrentSpeedChange))] + [SaveFlag(nameof(ShouldSerializeCurrentSpeed), nameof(CurrentSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _currentSpeed; - // Movement clock (seconds per step); 0 = inherit the matching think value. + private bool ShouldSerializeCurrentSpeed() => _currentSpeed != _passiveSpeed; + + private double CurrentSpeedDefaultValue() => _passiveSpeed; + + private void OnCurrentSpeedChange(double oldValue, double newValue) => AIObject?.OnCurrentSpeedChanged(); + + /// + /// Movement clock (seconds per step) while engaged; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed), fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(ActiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeMoveSpeed; + + /// + /// Movement clock (seconds per step) while idle; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(12, allowFieldChange: nameof(CoerceMoveSpeed), fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(PassiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveMoveSpeed; + private bool CoerceMoveSpeed(ref double value) + { + value = Math.Max(0, value); // anything non-positive means "inherit" + return true; + } + + private double ActiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return activeMoveSpeed; + } + + private double PassiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return passiveMoveSpeed; + } + + [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeHome))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _home; + + private bool ShouldSerializeHome() => _home != Point3D.Zero; + + [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeHomeMap))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Map _homeMap; + + private bool ShouldSerializeHomeMap() => _homeMap != null; + + [SerializableField(15, fieldChanged: nameof(OnControlledChange))] + [SaveFlag(nameof(ShouldSerializeControlled))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _controlled; + + private bool ShouldSerializeControlled() => _controlled; + + private void OnControlledChange(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + // ControlMaster and SummonMaster serialize as one master reference (Master below). + private Mobile _controlMaster; + + [SerializableField(16)] + [SaveFlag(nameof(ShouldSerializeControlTarget))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Mobile _controlTarget; + + private bool ShouldSerializeControlTarget() => _controlTarget != null; + + [SerializableField(17)] + [SaveFlag(nameof(ShouldSerializeControlDest))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _controlDest; + + private bool ShouldSerializeControlDest() => _controlDest != Point3D.Zero; + + // Field 18: ControlOrder (hand-written property; order logic must run on equal re-assignment) + private OrderType _controlOrder; + + private bool ShouldSerializeControlOrder() => _controlOrder != OrderType.None; + + [SerializableField(19)] + [SaveFlag(nameof(ShouldSerializeMinTameSkill))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private double _minTameSkill; + + private bool ShouldSerializeMinTameSkill() => _minTameSkill != 0; + + // Field 20: Tamable (hand-written property; custom getter masks paragons) + private bool _tamable; + + private bool ShouldSerializeTamable() => _tamable; + + [SerializableField(21, fieldChanged: nameof(OnSummonedChange))] + [SaveFlag(nameof(ShouldSerializeSummoned))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private bool _summoned; + + private bool ShouldSerializeSummoned() => _summoned; + + private void OnSummonedChange(bool oldValue, bool newValue) + { + NextReacquireTime = Core.TickCount; + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + [AnchoredDateTime] + [SerializableField(22, getter: "protected", setter: "protected")] + [SaveFlag(nameof(ShouldSerializeSummonEnd))] + private DateTime _summonEnd; + + private bool ShouldSerializeSummonEnd() => _summoned; + + private Mobile _summonMaster; + + // When both roles are set they are always the same mobile (every management flow + // assigns them in lockstep via SetControlMaster), so one reference serializes - + // refreshed here at save - and fans back out through the Controlled/Summoned + // flags in AfterDeserialization. + [SerializableField(23, getter: "private", setter: "private")] + [SaveFlag(nameof(ShouldSerializeMaster))] + private Mobile _master; + + private bool ShouldSerializeMaster() + { + _master = _controlMaster ?? _summonMaster; + return _master != null; + } + + [EncodedInt] + [SerializableField(24)] + [SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private int _controlSlots = 1; + + private bool ShouldSerializeControlSlots() => _controlSlots != 1; + + private int ControlSlotsDefaultValue() => 1; + + [EncodedInt] + [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))] + [SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _loyalty; + + private bool ShouldSerializeLoyalty() => _loyalty != MaxLoyalty; + + private int LoyaltyDefaultValue() => MaxLoyalty; + + private bool ClampLoyalty(ref int value) + { + value = Math.Clamp(value, 0, MaxLoyalty); + return true; + } + + [SerializableField(26)] + [SaveFlag(nameof(ShouldSerializeCurrentWayPoint))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private WayPoint _currentWayPoint; + + private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null; + + [EncodedInt] + [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _hitsMaxSeed = -1; + + private bool ShouldSerializeHitsMaxSeed() => _hitsMaxSeed != -1; + + private int HitsMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(28)] + [SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _stamMaxSeed = -1; + + private bool ShouldSerializeStamMaxSeed() => _stamMaxSeed != -1; + + private int StamMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _manaMaxSeed = -1; + + private bool ShouldSerializeManaMaxSeed() => _manaMaxSeed != -1; + + private int ManaMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(30)] + [SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMin = -1; + + private bool ShouldSerializeDamageMin() => _damageMin != -1; + + private int DamageMinDefaultValue() => -1; + + [EncodedInt] + [SerializableField(31)] + [SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMax = -1; + + private bool ShouldSerializeDamageMax() => _damageMax != -1; + + private int DamageMaxDefaultValue() => -1; + + [EncodedInt] + [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalResistanceSeed; + + private bool ShouldSerializePhysicalResistanceSeed() => _physicalResistanceSeed != 0; + + private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances(); + + [EncodedInt] + [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeFireResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireResistSeed; + + private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0; + + [EncodedInt] + [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeColdResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldResistSeed; + + private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0; + + [EncodedInt] + [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePoisonResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonResistSeed; + + private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0; + + [EncodedInt] + [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeEnergyResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyResistSeed; + + private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0; + + [EncodedInt] + [SerializableField(37)] + [SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalDamage = 100; + + private bool ShouldSerializePhysicalDamage() => _physicalDamage != 100; + + private int PhysicalDamageDefaultValue() => 100; + + [EncodedInt] + [SerializableField(38)] + [SaveFlag(nameof(ShouldSerializeFireDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireDamage; + + private bool ShouldSerializeFireDamage() => _fireDamage != 0; + + [EncodedInt] + [SerializableField(39)] + [SaveFlag(nameof(ShouldSerializeColdDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldDamage; + + private bool ShouldSerializeColdDamage() => _coldDamage != 0; + + [EncodedInt] + [SerializableField(40)] + [SaveFlag(nameof(ShouldSerializePoisonDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonDamage; + + private bool ShouldSerializePoisonDamage() => _poisonDamage != 0; + + [EncodedInt] + [SerializableField(41)] + [SaveFlag(nameof(ShouldSerializeEnergyDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyDamage; + + private bool ShouldSerializeEnergyDamage() => _energyDamage != 0; + + [Tidy] + [SerializableField(42, setter: "private")] + [SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))] + private List _owners; + + private bool ShouldSerializeOwners() + { + _owners?.Tidy(); + return _owners?.Count > 0; + } + + private List OwnersDefaultValue() => new(); + + [SerializableField(43)] + [SaveFlag(nameof(ShouldSerializeIsDeadPet))] + private bool _isDeadPet; + + private bool ShouldSerializeIsDeadPet() => _isDeadPet; + + [SerializableField(44, fieldChanged: nameof(OnBondedChange))] + [SaveFlag(nameof(ShouldSerializeIsBonded))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _isBonded; + + private bool ShouldSerializeIsBonded() => _isBonded; + + private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties(); + + [SerializableField(45)] + [SaveFlag(nameof(ShouldSerializeBondingBegin))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _bondingBegin; + + private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue; + + [SerializableField(46)] + [SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _ownerAbandonTime; + + private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue; + + [SerializableField(47)] + [SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))] + private bool _hasGeneratedLoot; + + private bool ShouldSerializeHasGeneratedLoot() => _hasGeneratedLoot; + + // Field 48: IsParagon (hand-written property; the setter converts, which must not run at load) + private bool _isParagon; + + private bool ShouldSerializeIsParagon() => _isParagon; + + [Tidy] + [SerializableField(49, setter: "private")] + [SaveFlag(nameof(ShouldSerializeFriends))] + private List _friends; + + private bool ShouldSerializeFriends() + { + _friends?.Tidy(); + return _friends?.Count > 0; + } + + [SerializableField(50)] + [SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _removeIfUntamed; + + private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed; + + [EncodedInt] + [SerializableField(51)] + [SaveFlag(nameof(ShouldSerializeRemoveStep))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _removeStep; + + private bool ShouldSerializeRemoveStep() => _removeStep != 0; + + [SerializableField(52, setter: "private")] + [SaveFlag(nameof(ShouldSerializePendingDeleteTimer))] + [DeserializeTimer(nameof(DeserializePendingDeleteTimer))] + private Timer _pendingDeleteTimer; + + // Stabled and controlled pets never resume a delete countdown (legacy parity). + private bool ShouldSerializePendingDeleteTimer() => + _pendingDeleteTimer?.Running == true && !IsStabled && !(_controlled && _controlMaster != null); + + private void DeserializePendingDeleteTimer(TimeSpan delay) + { + _pendingDeleteTimer = new DeleteTimer(this, delay); + _pendingDeleteTimer.Start(); + } + + [SerializableField(53)] + [SaveFlag(nameof(ShouldSerializeCorpseNameOverride))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _corpseNameOverride; + + private bool ShouldSerializeCorpseNameOverride() => _corpseNameOverride != null; + + // --- Non-serialized state ------------------------------------------------------- + // Herding - forces the mob to walk to a specific location, paced by the movement // clock at HerdingMoveSpeed. Thinking is unaffected. private IPoint2D _targetLocation; - private int m_DamageMax = -1; - - private int m_DamageMin = -1; - private AIType m_DefaultAI; // The default AI - - private DeleteTimer m_DeleteTimer; - private int m_EnergyResistance; - private int m_FailedReturnHome; /* return to home failure counter */ - private int m_FireResistance; - private bool m_HasGeneratedLoot; // have we generated our loot yet? private TimerExecutionToken _healTimerToken; - private Point3D m_Home; // The home position of the creature, used by some AI - private DateTime m_IdleReleaseTime; - private bool m_IsBonded; - private bool m_IsStabled; protected int m_KillersLuck; - private int m_Loyalty; - private DateTime m_MLNextShout; private List m_MLQuests; @@ -310,24 +860,13 @@ namespace Server.Mobiles private long m_NextRummageTime; - private bool m_Paragon; - - private int m_PhysicalResistance; - private int m_PoisonResistance; - - /* until we are sure about who should be getting deleted, move them instead */ - /* On OSI, they despawn */ - + // On OSI these despawn; we queue a return home instead of deleting. private bool m_ReturnQueued; protected bool m_Spawning; - private Mobile m_SummonMaster; - private SkillName m_Teaching = (SkillName)(-1); - private int m_Team; // Monster Team - public BaseCreature( AIType ai, FightMode mode = FightMode.Closest, @@ -335,33 +874,41 @@ namespace Server.Mobiles int iRangeFight = 1 ) { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; - m_CurrentAI = ai; - m_DefaultAI = ai; + _currentAI = ai; + _defaultAI = ai; + + _speedClass = ResolvedDefaultSpeedClass; RangePerception = iRangePerception; RangeFight = iRangeFight; FightMode = mode; - GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetSpeeds(out _activeSpeed, out _passiveSpeed); GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + _currentSpeed = _passiveSpeed; - ActiveSpeed = activeSpeed; - PassiveSpeed = passiveSpeed; - CurrentSpeed = passiveSpeed; + if (_activeSpeed <= 0 || _passiveSpeed <= 0) + { + // A 0-delay creature would spin its AI timer at wheel resolution. + logger.Debug("{Type} constructed without speeds - is Data/npc-speeds.json missing? Defaulting to Medium.", GetType()); + _activeSpeed = 0.25; + _passiveSpeed = 0.5; + _currentSpeed = _passiveSpeed; + } - m_Team = 0; + _team = 0; Debug = false; _controlled = false; - m_ControlMaster = null; + _controlMaster = null; ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlOrder = OrderType.None; - m_bTamable = false; + _tamable = false; Owners = new List(); @@ -383,6 +930,7 @@ namespace Server.Mobiles public BaseCreature(Serial serial) : base(serial) { + _speedClass = ResolvedDefaultSpeedClass; Debug = false; } @@ -406,14 +954,10 @@ namespace Server.Mobiles public virtual InhumanSpeech SpeechType => null; - /* Do not serialize this till the code is finalized */ - + // Deliberately not serialized until the feature is finalized. [CommandProperty(AccessLevel.GameMaster)] public bool SeeksHome { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public string CorpseNameOverride { get; set; } - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool IsStabled { @@ -436,20 +980,20 @@ namespace Server.Mobiles public virtual bool FollowsAcquireRules => true; - protected DateTime SummonEnd { get; set; } - public virtual Faction FactionAllegiance => null; public virtual int FactionSilverWorth => 30; public virtual double WeaponAbilityChance => 0.4; + [SerializableProperty(48, useField: nameof(_isParagon))] + [SaveFlag(nameof(ShouldSerializeIsParagon))] [CommandProperty(AccessLevel.GameMaster)] public bool IsParagon { - get => m_Paragon; + get => _isParagon; set { - if (m_Paragon == value) + if (_isParagon == value) { return; } @@ -463,9 +1007,10 @@ namespace Server.Mobiles Paragon.UnConvert(this); } - m_Paragon = value; + _isParagon = value; InvalidateProperties(); + this.MarkDirty(); } } @@ -474,8 +1019,6 @@ namespace Server.Mobiles public virtual FoodType FavoriteFood => FoodType.Meat; public virtual PackInstinct PackInstinct => PackInstinct.None; - public List Owners { get; private set; } - public virtual bool AllowMaleTamer => true; public virtual bool AllowFemaleTamer => true; public virtual bool SubdueBeforeTame => false; @@ -499,11 +1042,11 @@ namespace Server.Mobiles public virtual bool DeathAdderCharmable => false; - // TODO: Find the pub 31 tweaks to the DispelDifficulty and apply them of course. - // at this skill level we dispel 50% chance + //TODO Apply the pub 31 DispelDifficulty tweaks + // Skill level at which dispel succeeds 50% of the time. public virtual double DispelDifficulty => 0.0; - // at difficulty - focus we have 0%, at difficulty + focus we have 100% + // 0% at difficulty - focus, 100% at difficulty + focus. public virtual double DispelFocus => 20.0; public virtual bool DisplayWeight => Backpack is StrongBackpack; @@ -539,21 +1082,11 @@ namespace Server.Mobiles } public virtual bool IsNecroFamiliar => - Summoned && m_ControlMaster != null && - SummonFamiliarSpell.Table.TryGetValue(m_ControlMaster, out var bc) && bc == this; + Summoned && _controlMaster != null && + SummonFamiliarSpell.Table.TryGetValue(_controlMaster, out var bc) && bc == this; public virtual bool DeleteCorpseOnDeath => !Core.AOS && _summoned; - [CommandProperty(AccessLevel.GameMaster)] - public int Loyalty - { - get => m_Loyalty; - set => m_Loyalty = Math.Clamp(value, 0, MaxLoyalty); - } - - [CommandProperty(AccessLevel.GameMaster)] - public WayPoint CurrentWayPoint { get; set; } - public virtual Mobile ConstantFocus => null; public virtual bool DisallowAllMoves => false; @@ -566,57 +1099,25 @@ namespace Server.Mobiles public virtual bool AlwaysAttackable => false; - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMin - { - get => m_DamageMin; - set => m_DamageMin = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMax - { - get => m_DamageMax; - set => m_DamageMax = value; - } - [CommandProperty(AccessLevel.GameMaster)] public override int HitsMax => HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int HitsMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int StamMax => StamMaxSeed <= 0 ? Dex : Math.Clamp(StamMaxSeed + GetStatOffset(StatType.Dex), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int StamMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int ManaMax => ManaMaxSeed <= 0 ? Int : Math.Clamp(ManaMaxSeed + GetStatOffset(StatType.Int), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int ManaMaxSeed { get; set; } = -1; - public virtual bool CanOpenDoors => !Body.IsAnimal && !Body.IsSea; public virtual bool CanMoveOverObstacles => Core.AOS || Body.IsMonster; public virtual bool CanDestroyObstacles => false; - /* - Seems this actually was removed on OSI somewhere between the original bug report and now. - We will call it ML, until we can get better information. I suspect it was on the OSI TC when - originally it taken out of RunUO, and not implemented on OSIs production shards until more - recently. Either way, this is, or was, accurate OSI behavior, and just entirely - removing it was incorrect. OSI followers were distracted by being attacked well into - AoS, at very least. - - */ - + // OSI followers were distracted by attacks well into AoS; removed around ML. public virtual bool CanBeDistracted => !Core.ML; public override bool ShouldCheckStatTimers => false; @@ -628,43 +1129,26 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public AIType AI { - get => m_CurrentAI; + get => _currentAI; set { - m_CurrentAI = value; + _currentAI = value; - if (m_CurrentAI == AIType.AI_Use_Default) + if (_currentAI == AIType.AI_Use_Default) { - m_CurrentAI = m_DefaultAI; + _currentAI = _defaultAI; } - ChangeAIType(m_CurrentAI); + ChangeAIType(_currentAI); } } [CommandProperty(AccessLevel.Administrator)] public bool Debug { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public int Team - { - get => m_Team; - set - { - m_Team = value; - OnTeamChange(); - } - } - [CommandProperty(AccessLevel.GameMaster)] public Mobile FocusMob { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public FightMode FightMode { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangePerception { get; set; } - /// /// How far a chase may stretch before the creature gives up its combatant. Between /// RangePerception and this leash it keeps chasing but may switch to closer targets. @@ -672,57 +1156,6 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public virtual int ChaseLeashRange => RangePerception * 2; - [CommandProperty(AccessLevel.GameMaster)] - public int RangeFight { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangeHome { get; set; } = 10; - - /// Seconds per AI decision while engaged; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveSpeed - { - get => _activeSpeed; - set - { - if (Math.Abs(_activeSpeed - value) > .0001) - { - _activeSpeed = value; - } - } - } - - /// Seconds per AI decision while idle; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveSpeed - { - get => _passiveSpeed; - set - { - _passiveSpeed = value; - if (Math.Abs(_passiveSpeed - value) > .0001) - { - _passiveSpeed = value; - } - } - } - - /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveMoveSpeed - { - get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; - set => _activeMoveSpeed = value > 0 ? value : 0; - } - - /// Seconds per step while idle. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveMoveSpeed - { - get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; - set => _passiveMoveSpeed = value > 0 ? value : 0; - } - // Herded creatures walk at a fixed standard pace regardless of their own speed // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). private const double HerdingMoveSpeed = 0.3; @@ -734,25 +1167,10 @@ namespace Server.Mobiles set => _targetLocation = value; } - [CommandProperty(AccessLevel.GameMaster)] - public double CurrentSpeed - { - get => _currentSpeed; - set - { - if (Math.Abs(_currentSpeed - value) > 0.0001) - { - _currentSpeed = value; - AIObject?.OnCurrentSpeedChanged(); - } - } - } - /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) - /// stays fused to both clocks. A herded creature is always driven at - /// . + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. + /// A herded creature is always driven at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -764,104 +1182,83 @@ namespace Server.Mobiles return HerdingMoveSpeed; } - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed - : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed - : _currentSpeed; - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D Home - { - get => m_Home; - set => m_Home = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public Map HomeMap { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public bool Controlled - { - get => _controlled; - set - { - if (_controlled == value) + if (_currentSpeed == _activeSpeed) { - return; + return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; } - _controlled = value; - Delta(MobileDelta.Noto); + if (_currentSpeed == _passiveSpeed) + { + return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + } - InvalidateProperties(); + return _currentSpeed; } } [CommandProperty(AccessLevel.GameMaster)] public Mobile ControlMaster { - get => m_ControlMaster; + get => _controlMaster; set { - if (m_ControlMaster == value || this == value) + if (_controlMaster == value || this == value) { return; } RemoveFollowers(); - m_ControlMaster = value; + _controlMaster = value; AddFollowers(); - if (m_ControlMaster != null) + if (_controlMaster != null) { StopDeleteTimer(); } Delta(MobileDelta.Noto); + this.MarkDirty(); } } [CommandProperty(AccessLevel.GameMaster)] public Mobile SummonMaster { - get => m_SummonMaster; + get => _summonMaster; set { - if (m_SummonMaster == value || this == value) + if (_summonMaster == value || this == value) { return; } RemoveFollowers(); - m_SummonMaster = value; + _summonMaster = value; AddFollowers(); Delta(MobileDelta.Noto); + this.MarkDirty(); } } - [CommandProperty(AccessLevel.GameMaster)] - public Mobile ControlTarget { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D ControlDest { get; set; } - - // Fires on every assignment, not only changes: a reissued order is a command - // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. + // Re-issuing the current order must still run the order logic (pet commands), so + // this keeps a hand-written setter with no equality skip. + [SerializableProperty(18, useField: nameof(_controlOrder))] + [SaveFlag(nameof(ShouldSerializeControlOrder))] [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { - get => m_ControlOrder; + get => _controlOrder; set { - var previous = m_ControlOrder; - m_ControlOrder = value; + var previous = _controlOrder; + _controlOrder = value; AIObject?.OnCurrentOrderChanged(previous); InvalidateProperties(); - m_ControlMaster?.InvalidateProperties(); + _controlMaster?.InvalidateProperties(); + this.MarkDirty(); } } @@ -880,39 +1277,19 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public DateTime BardEndTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public double MinTameSkill { get; set; } - + [SerializableProperty(20, useField: nameof(_tamable))] + [SaveFlag(nameof(ShouldSerializeTamable))] [CommandProperty(AccessLevel.GameMaster)] public bool Tamable { - get => m_bTamable && !m_Paragon; - set => m_bTamable = value; - } - - [CommandProperty(AccessLevel.Administrator)] - public bool Summoned - { - get => _summoned; + get => _tamable && !_isParagon; set { - if (_summoned == value) - { - return; - } - - NextReacquireTime = Core.TickCount; - - _summoned = value; - Delta(MobileDelta.Noto); - - InvalidateProperties(); + _tamable = value; + this.MarkDirty(); } } - [CommandProperty(AccessLevel.Administrator)] - public int ControlSlots { get; set; } = 1; - public virtual bool NoHouseRestrictions => false; public virtual bool IsHouseSummonable => false; @@ -936,49 +1313,14 @@ namespace Server.Mobiles public virtual bool GivesMLMinorArtifact => false; + // Reacquire only every ReacquireDelay, when attacked, or (for some creatures) on + // seeing movement - OSI parity and a CPU saver. public long NextReacquireTime { get; set; } public virtual TimeSpan ReacquireDelay => TimeSpan.FromSeconds(10.0); - - // Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the - // next scan to at most this far away. Zero (paragons) scans on the very next - // think; larger is dumber; pure ReacquireDelay is the oblivious floor. - public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); - - // Reactive range is tighter than the periodic scan's RangePerception: approach - // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. - public virtual int AcquireOnApproachRange => 10; - - // Clamps the scan deadline rather than opening the gate: repeated steps cannot - // shorten it further, so an armed creature scans once per delay period. - private void ScheduleAcquireOnApproach() - { - var delay = (long)AcquireOnApproachDelay.TotalMilliseconds; - var deadline = Core.TickCount + delay; - - if (deadline - NextReacquireTime < 0) - { - NextReacquireTime = deadline; - } - - if (delay <= 0) - { - // Zero: think now — the ranked scan engages within a wheel turn. Prod is - // spam-safe; the Combatant == null guard stops the prods once engaged. - AIObject?.AITimer?.Prod(); - } - } - - // IsEnemy first — it cheaply rejects the common case (a same-team wild creature - // wandering past); CanBeHarmful covers hidden movers via CanSee. - private bool ShouldAcquireOnApproach(Mobile m) => - Combatant == null && - !Controlled && !Summoned && !BardPacified && - FightMode != FightMode.None && FightMode != FightMode.Aggressor && - InRange(m.Location, AcquireOnApproachRange) && - IsEnemy(m) && CanBeHarmful(m, false); - public virtual bool ReacquireOnMovement => false; + public virtual bool AcquireOnApproach => _isParagon; + public virtual int AcquireOnApproachRange => 10; public static bool Summoning { get; set; } @@ -990,13 +1332,6 @@ namespace Server.Mobiles public virtual bool ReturnsToHome => SeeksHome && Home != Point3D.Zero && !m_ReturnQueued && !Controlled && !Summoned; - // used for deleting untamed creatures [in houses] - [CommandProperty(AccessLevel.GameMaster)] - public bool RemoveIfUntamed { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RemoveStep { get; set; } - public virtual bool CanGiveMLQuest => MLQuests.Count != 0; public virtual bool StaticMLQuester => true; @@ -1031,114 +1366,25 @@ namespace Server.Mobiles } } - [CommandProperty(AccessLevel.GameMaster)] - public bool IsBonded - { - get => m_IsBonded; - set - { - m_IsBonded = value; - InvalidateProperties(); - } - } - - public bool IsDeadPet { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime BondingBegin { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime OwnerAbandonTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public TimeSpan DeleteTimeLeft { get { - if (m_DeleteTimer?.Running == true) + if (_pendingDeleteTimer?.Running == true) { - return m_DeleteTimer.Next - Core.Now; + return _pendingDeleteTimer.Next - Core.Now; } return TimeSpan.Zero; } } - public override int BasePhysicalResistance => m_PhysicalResistance; - public override int BaseFireResistance => m_FireResistance; - public override int BaseColdResistance => m_ColdResistance; - public override int BasePoisonResistance => m_PoisonResistance; - public override int BaseEnergyResistance => m_EnergyResistance; - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalResistanceSeed - { - get => m_PhysicalResistance; - set - { - m_PhysicalResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int FireResistSeed - { - get => m_FireResistance; - set - { - m_FireResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdResistSeed - { - get => m_ColdResistance; - set - { - m_ColdResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonResistSeed - { - get => m_PoisonResistance; - set - { - m_PoisonResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyResistSeed - { - get => m_EnergyResistance; - set - { - m_EnergyResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalDamage { get; set; } = 100; - - [CommandProperty(AccessLevel.GameMaster)] - public int FireDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyDamage { get; set; } + public override int BasePhysicalResistance => _physicalResistanceSeed; + public override int BaseFireResistance => _fireResistSeed; + public override int BaseColdResistance => _coldResistSeed; + public override int BasePoisonResistance => _poisonResistSeed; + public override int BaseEnergyResistance => _energyResistSeed; [CommandProperty(AccessLevel.GameMaster)] public int ChaosDamage { get; set; } @@ -1146,15 +1392,12 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int DirectDamage { get; set; } - // Is immune to breath damages public virtual bool BreathImmune => false; - public virtual bool CanFlee => !m_Paragon; + public virtual bool CanFlee => !_isParagon; public DateTime EndFleeTime { get; set; } - public List Friends { get; private set; } - public virtual bool AllowNewPetFriend => Friends == null || Friends.Count < 5; public virtual Ethic EthicAllegiance => null; @@ -1206,7 +1449,6 @@ namespace Server.Mobiles public HonorContext ReceivedHonorContext { get; set; } public List MLQuests => - // Assign the quests if we don't have one, and if it is still null, return an empty list (m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList; public virtual MonsterAbility[] GetMonsterAbilities() => null; @@ -1432,7 +1674,7 @@ namespace Server.Mobiles return false; } - if (m_Team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) + if (_team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) { return true; } @@ -1550,7 +1792,7 @@ namespace Server.Mobiles var chance = Math.Clamp(700 + bonus, 220, 990); - chance -= (MaxLoyalty - m_Loyalty) * 10; + chance -= (MaxLoyalty - _loyalty) * 10; return chance / 1000.0; } @@ -1642,7 +1884,7 @@ namespace Server.Mobiles public override bool CheckPoisonImmunity(Mobile from, Poison poison) => base.CheckPoisonImmunity(from, poison) || - (m_Paragon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; + (_isParagon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; public void Unpacify() { @@ -1669,7 +1911,6 @@ namespace Server.Mobiles } int disruptThreshold; - // NPCs can use bandages too! if (!Core.AOS) { disruptThreshold = 0; @@ -1919,163 +2160,29 @@ namespace Server.Mobiles } } - public override void Serialize(IGenericWriter writer) + // Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom. + private void Deserialize(IGenericReader reader, int version) { - base.Serialize(writer); - writer.Write(22); // version + _currentAI = (AIType)reader.ReadInt(); + _defaultAI = (AIType)reader.ReadInt(); - writer.Write((int)m_CurrentAI); - writer.Write((int)m_DefaultAI); + _rangePerception = reader.ReadInt(); + _rangeFight = reader.ReadInt(); - writer.Write(RangePerception); - writer.Write(RangeFight); - - writer.Write(m_Team); - - writer.Write(_activeSpeed); - writer.Write(_passiveSpeed); - writer.Write(_currentSpeed); - - writer.Write(m_Home.X); - writer.Write(m_Home.Y); - writer.Write(m_Home.Z); - - // Version 1 - writer.Write(RangeHome); - - // Version 2 - writer.Write((int)FightMode); - - writer.Write(_controlled); - writer.Write(m_ControlMaster); - writer.Write(ControlTarget); - writer.Write(ControlDest); - writer.Write((int)m_ControlOrder); - writer.Write(MinTameSkill); - // Removed in version 9 - // writer.Write( (double) m_dMaxTameSkill ); - writer.Write(m_bTamable); - writer.Write(_summoned); - - if (_summoned) - { - writer.WriteAnchoredTime(SummonEnd); - } - - writer.Write(ControlSlots); - - // Version 3 - writer.Write(m_Loyalty); - - // Version 4 - writer.Write(CurrentWayPoint); - - // Verison 5 - writer.Write(m_SummonMaster); - - // Version 6 - writer.Write(HitsMaxSeed); - writer.Write(StamMaxSeed); - writer.Write(ManaMaxSeed); - writer.Write(m_DamageMin); - writer.Write(m_DamageMax); - - // Version 7 - writer.Write(m_PhysicalResistance); - writer.Write(PhysicalDamage); - - writer.Write(m_FireResistance); - writer.Write(FireDamage); - - writer.Write(m_ColdResistance); - writer.Write(ColdDamage); - - writer.Write(m_PoisonResistance); - writer.Write(PoisonDamage); - - writer.Write(m_EnergyResistance); - writer.Write(EnergyDamage); - - // Version 8 - Owners.Tidy(); - writer.Write(Owners); - - // Version 10 - writer.Write(IsDeadPet); - writer.Write(m_IsBonded); - writer.Write(BondingBegin); - writer.Write(OwnerAbandonTime); - - // Version 11 - writer.Write(m_HasGeneratedLoot); - - // Version 12 - writer.Write(m_Paragon); - - var hasFriends = Friends?.Count > 0; - - // Version 13 - writer.Write(hasFriends); - - if (hasFriends) - { - Friends.Tidy(); - writer.Write(Friends); - } - - // Version 14 - writer.Write(RemoveIfUntamed); - writer.Write(RemoveStep); - - // Version 17 - if (IsStabled || Controlled && ControlMaster != null) - { - writer.Write(TimeSpan.Zero); - } - else - { - writer.Write(DeleteTimeLeft); - } - - // Version 18 - writer.Write(CorpseNameOverride); - - // Version 19 - writer.Write(HomeMap); - - // Version 22 (0 = inherit the matching think value) - writer.Write(_activeMoveSpeed); - writer.Write(_passiveMoveSpeed); - } - - public override void Deserialize(IGenericReader reader) - { - base.Deserialize(reader); - - NextReacquireTime = Core.TickCount; - - var version = reader.ReadInt(); - - m_CurrentAI = (AIType)reader.ReadInt(); - m_DefaultAI = (AIType)reader.ReadInt(); - - RangePerception = reader.ReadInt(); - RangeFight = reader.ReadInt(); - - m_Team = reader.ReadInt(); + _team = reader.ReadInt(); _activeSpeed = reader.ReadDouble(); _passiveSpeed = reader.ReadDouble(); _currentSpeed = reader.ReadDouble(); - m_Home.X = reader.ReadInt(); - m_Home.Y = reader.ReadInt(); - m_Home.Z = reader.ReadInt(); + _home.X = reader.ReadInt(); + _home.Y = reader.ReadInt(); + _home.Z = reader.ReadInt(); if (version >= 1) { - RangeHome = reader.ReadInt(); + _rangeHome = reader.ReadInt(); if (version < 20) { @@ -2096,121 +2203,121 @@ namespace Server.Mobiles } else { - RangeHome = 0; + _rangeHome = 0; } if (version >= 2) { - FightMode = (FightMode)reader.ReadInt(); + _fightMode = (FightMode)reader.ReadInt(); _controlled = reader.ReadBool(); - m_ControlMaster = reader.ReadEntity(); - ControlTarget = reader.ReadEntity(); - ControlDest = reader.ReadPoint3D(); - m_ControlOrder = (OrderType)reader.ReadInt(); + _controlMaster = reader.ReadEntity(); + _controlTarget = reader.ReadEntity(); + _controlDest = reader.ReadPoint3D(); + _controlOrder = (OrderType)reader.ReadInt(); - MinTameSkill = reader.ReadDouble(); + _minTameSkill = reader.ReadDouble(); if (version < 9) { reader.ReadDouble(); } - m_bTamable = reader.ReadBool(); + _tamable = reader.ReadBool(); _summoned = reader.ReadBool(); if (_summoned) { - SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - new UnsummonTimer(this, SummonEnd - Core.Now).Start(); + // The UnsummonTimer is restarted in AfterDeserialization. + _summonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); } - ControlSlots = reader.ReadInt(); + _controlSlots = reader.ReadInt(); } else { - FightMode = FightMode.Closest; + _fightMode = FightMode.Closest; _controlled = false; - m_ControlMaster = null; - ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlMaster = null; + _controlTarget = null; + _controlOrder = OrderType.None; } if (version >= 3) { - m_Loyalty = reader.ReadInt(); + _loyalty = reader.ReadInt(); } else { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; } if (version >= 4) { - CurrentWayPoint = reader.ReadEntity(); + _currentWayPoint = reader.ReadEntity(); } if (version >= 5) { - m_SummonMaster = reader.ReadEntity(); + _summonMaster = reader.ReadEntity(); } if (version >= 6) { - HitsMaxSeed = reader.ReadInt(); - StamMaxSeed = reader.ReadInt(); - ManaMaxSeed = reader.ReadInt(); - m_DamageMin = reader.ReadInt(); - m_DamageMax = reader.ReadInt(); + _hitsMaxSeed = reader.ReadInt(); + _stamMaxSeed = reader.ReadInt(); + _manaMaxSeed = reader.ReadInt(); + _damageMin = reader.ReadInt(); + _damageMax = reader.ReadInt(); } if (version >= 7) { - m_PhysicalResistance = reader.ReadInt(); - PhysicalDamage = reader.ReadInt(); + _physicalResistanceSeed = reader.ReadInt(); + _physicalDamage = reader.ReadInt(); - m_FireResistance = reader.ReadInt(); - FireDamage = reader.ReadInt(); + _fireResistSeed = reader.ReadInt(); + _fireDamage = reader.ReadInt(); - m_ColdResistance = reader.ReadInt(); - ColdDamage = reader.ReadInt(); + _coldResistSeed = reader.ReadInt(); + _coldDamage = reader.ReadInt(); - m_PoisonResistance = reader.ReadInt(); - PoisonDamage = reader.ReadInt(); + _poisonResistSeed = reader.ReadInt(); + _poisonDamage = reader.ReadInt(); - m_EnergyResistance = reader.ReadInt(); - EnergyDamage = reader.ReadInt(); + _energyResistSeed = reader.ReadInt(); + _energyDamage = reader.ReadInt(); } if (version >= 8) { - Owners = reader.ReadEntityList(); + _owners = reader.ReadEntityList(); } else { - Owners = new List(); + _owners = new List(); } if (version >= 10) { - IsDeadPet = reader.ReadBool(); - m_IsBonded = reader.ReadBool(); - BondingBegin = reader.ReadDateTime(); - OwnerAbandonTime = reader.ReadDateTime(); + _isDeadPet = reader.ReadBool(); + _isBonded = reader.ReadBool(); + _bondingBegin = reader.ReadDateTime(); + _ownerAbandonTime = reader.ReadDateTime(); } - m_HasGeneratedLoot = version < 11 || reader.ReadBool(); + _hasGeneratedLoot = version < 11 || reader.ReadBool(); - m_Paragon = version >= 12 && reader.ReadBool(); + _isParagon = version >= 12 && reader.ReadBool(); if (version >= 13 && reader.ReadBool()) { - Friends = reader.ReadEntityList(); + _friends = reader.ReadEntityList(); } - else if (version < 13 && m_ControlOrder >= OrderType.Unfriend) + else if (version < 13 && _controlOrder >= OrderType.Unfriend) { - ++m_ControlOrder; + ++_controlOrder; } if (version < 16 && Loyalty != MaxLoyalty) @@ -2220,8 +2327,8 @@ namespace Server.Mobiles if (version >= 14) { - RemoveIfUntamed = reader.ReadBool(); - RemoveStep = reader.ReadInt(); + _removeIfUntamed = reader.ReadBool(); + _removeStep = reader.ReadInt(); } var deleteTime = TimeSpan.Zero; @@ -2238,18 +2345,18 @@ namespace Server.Mobiles deleteTime = TimeSpan.FromDays(3.0); } - m_DeleteTimer = new DeleteTimer(this, deleteTime); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, deleteTime); + _pendingDeleteTimer.Start(); } if (version >= 18) { - CorpseNameOverride = reader.ReadString(); + _corpseNameOverride = reader.ReadString(); } if (version >= 19) { - HomeMap = reader.ReadMap(); + _homeMap = reader.ReadMap(); } if (version >= 22) @@ -2262,25 +2369,57 @@ namespace Server.Mobiles MigrateMoveSpeeds(); } - if (version <= 14 && m_Paragon && Hue == 0x31) + // Legacy saves carry no bucket; the ctor guessed the type default. If the + // loaded speeds do not conform, the creature is custom. + if (_speedClass != SpeedLevel.None && ShouldSerializeSpeeds()) + { + _speedClass = SpeedLevel.None; + _speedEntry = null; + } + + // Feed the masters through the consolidated reference so the + // AfterDeserialization fan-out is uniform across both load paths. + _master = _controlMaster ?? _summonMaster; + + if (version <= 14 && _isParagon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. } + } + + [AfterDeserialization] + private void AfterDeserialization() + { + _controlMaster = _controlled ? _master : null; + _summonMaster = _summoned ? _master : null; if (Core.AOS && NameHue == 0x35) { NameHue = -1; } + if (_summoned) + { + new UnsummonTimer(this, _summonEnd - Core.Now).Start(); + } + + // Abandoned-pet fallback: a pet with a former owner but no persisted delete + // countdown still despawns (legacy loads restore their own timer above). + if (_pendingDeleteTimer == null && LastOwner != null && !_controlled && !IsStabled) + { + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + } + CheckStatTimers(); - ChangeAIType(m_CurrentAI); + ChangeAIType(_currentAI); AddFollowers(); if (IsAnimatedDead) { - AnimateDeadSpell.Register(m_SummonMaster, this); + AnimateDeadSpell.Register(_summonMaster, this); } } @@ -2335,8 +2474,7 @@ namespace Server.Mobiles return true; } - // Note: Yes, this happens for all questers (regardless of type, e.g. escorts), - // even if they can't offer you anything at the moment + // Happens for all questers, even those with nothing to offer right now. if (MLQuestSystem.Enabled && CanGiveMLQuest && from is PlayerMobile mobile) { // You need to mark your quest items so I don't take the wrong object. Then speak to me. @@ -2367,7 +2505,7 @@ namespace Server.Mobiles AIType.AI_Vendor => new VendorAI(this), AIType.AI_Mage => new MageAI(this), AIType.AI_Predator => - // m_AI = new PredatorAI(this); + //TODO Implement PredatorAI new MeleeAI(this), AIType.AI_Thief => new ThiefAI(this), _ => null @@ -2387,7 +2525,7 @@ namespace Server.Mobiles public void RemoveFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers -= Math.Min(ControlSlots, master.Followers); @@ -2401,7 +2539,7 @@ namespace Server.Mobiles public void AddFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers += ControlSlots; @@ -2447,7 +2585,7 @@ namespace Server.Mobiles public virtual void OnGaveMeleeAttack(Mobile defender, int damage) { - var p = m_Paragon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; + var p = _isParagon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; if (p != null && HitPoisonChance >= Utility.RandomDouble()) { @@ -2476,17 +2614,17 @@ namespace Server.Mobiles AIObject = null; } - if (m_DeleteTimer != null) + if (_pendingDeleteTimer != null) { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; + _pendingDeleteTimer.Stop(); + _pendingDeleteTimer = null; } FocusMob = null; if (IsAnimatedDead) { - AnimateDeadSpell.Unregister(m_SummonMaster, this); + AnimateDeadSpell.Unregister(_summonMaster, this); } if (Summoned && SummonMaster != null) @@ -2505,13 +2643,6 @@ namespace Server.Mobiles base.OnAfterDelete(); } - /* - * This function can be overridden.. so a "Strongest" mobile, can have a different definition depending - * on who check for value - * -Could add a FightMode.Preferred - * - */ - public virtual double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) { if (bPlayerOnly && !m.Player) @@ -2527,8 +2658,7 @@ namespace Server.Mobiles }; } - // Turn, - for left, + for right - // Basic for now, needs work + // Turn: negative = left, positive = right. public virtual void Turn(int iTurnSteps) { var v = (int)Direction; @@ -2545,7 +2675,7 @@ namespace Server.Mobiles public bool IsHurt() => Hits != HitsMax; - public double GetHomeDistance() => this.GetDistanceToSqrt(m_Home); + public double GetHomeDistance() => this.GetDistanceToSqrt(_home); public virtual int GetTeamSize(int iRange) { @@ -2572,7 +2702,7 @@ namespace Server.Mobiles aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } - var ct = m_ControlOrder; + var ct = _controlOrder; if (AIObject != null) { @@ -2646,7 +2776,7 @@ namespace Server.Mobiles AIObject?.GetContextMenuEntries(from, ref list); } - if (m_bTamable && !_controlled && from.Alive) + if (_tamable && !_controlled && from.Alive) { list.Add(new TameEntry(from.Female ? AllowFemaleTamer : AllowMaleTamer)); } @@ -2697,7 +2827,7 @@ namespace Server.Mobiles } public override bool IsHarmfulCriminal(Mobile target) => - (!Controlled || target != m_ControlMaster) && (!Summoned || target != m_SummonMaster) && + (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) && (target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) && (target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target); @@ -2707,13 +2837,13 @@ namespace Server.Mobiles if (Controlled || Summoned) { - if (m_ControlMaster?.Player == true) + if (_controlMaster?.Player == true) { - m_ControlMaster.CriminalAction(false); + _controlMaster.CriminalAction(false); } - else if (m_SummonMaster?.Player == true) + else if (_summonMaster?.Player == true) { - m_SummonMaster.CriminalAction(false); + _summonMaster.CriminalAction(false); } } } @@ -2722,7 +2852,7 @@ namespace Server.Mobiles { base.DoHarmful(target, indirect); - if (target == this || target == m_ControlMaster || target == m_SummonMaster || !Controlled && !Summoned) + if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned) { return; } @@ -2772,12 +2902,11 @@ namespace Server.Mobiles { if (Combatant != null) { - return false; // in combat.. not idling + return false; // in combat, not idling } if (m_IdleReleaseTime > DateTime.MinValue) { - // idling... if (Core.Now >= m_IdleReleaseTime) { m_IdleReleaseTime = DateTime.MinValue; @@ -2789,7 +2918,7 @@ namespace Server.Mobiles if (Utility.Random(100) < 95) { - return false; // not idling, but don't want to enter idle state + return false; // chose not to enter the idle state } var idleSeconds = Utility.RandomMinMax(NPCSpeeds.MinIdleSeconds, NPCSpeeds.MaxIdleSeconds); @@ -2829,12 +2958,6 @@ namespace Server.Mobiles return true; // entered idle state } - /* - this way, due to the huge number of locations this will have to be changed - Perhaps we can change this in the future when fixing game play is not the - major issue. - */ - public virtual void CheckedAnimate(int action, int frameCount, int repeatCount, bool forward, bool repeat, int delay) { if (!Mounted) @@ -2886,9 +3009,15 @@ namespace Server.Mobiles public override void OnMovement(Mobile m, Point3D oldLocation) { - if (ShouldAcquireOnApproach(m)) + if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor) { - ScheduleAcquireOnApproach(); + if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) && + CanBeHarmful(m) && IsEnemy(m)) + { + Combatant = FocusMob = m; + AIObject?.MoveTo(m, true, 1); + DoHarmful(m); + } } else if (ReacquireOnMovement) { @@ -2897,7 +3026,7 @@ namespace Server.Mobiles SpeechType?.OnMovement(this, m, oldLocation); - /* Begin notice sound */ + // Notice sound if ((!m.Hidden || m.AccessLevel == AccessLevel.Player) && m.Player && FightMode != FightMode.Aggressor && FightMode != FightMode.None && Combatant == null && !Controlled && !Summoned && !BardPacified && InRange(m.Location, 18) && !InRange(oldLocation, 18)) @@ -2909,7 +3038,6 @@ namespace Server.Mobiles PlaySound(GetAngerSound()); } - /* End notice sound */ if (MLQuestSystem.Enabled && CanShout && m is PlayerMobile mobile) { @@ -2983,7 +3111,7 @@ namespace Server.Mobiles list.Add(TotalWeight == 1 ? 1072788 : 1072789, TotalWeight); // Weight: ~1_WEIGHT~ stones } - if (m_ControlOrder == OrderType.Guard) + if (_controlOrder == OrderType.Guard) { list.Add(1080078); // guarding } @@ -2995,7 +3123,7 @@ namespace Server.Mobiles } else if (Controlled && Commandable) { - // Intentional difference (showing ONLY bonded when bonded instead of bonded & tame) + // Deliberate: show only (bonded), never (bonded) and (tame) together. if (IsBonded) { list.Add(1049608); // (bonded) @@ -3057,7 +3185,7 @@ namespace Server.Mobiles { if (treasureLevel >= 0) { - if (m_Paragon && Paragon.ChestChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChestChance > Utility.RandomDouble()) { PackItem(new ParagonChest(Name, treasureLevel)); } @@ -3067,7 +3195,7 @@ namespace Server.Mobiles } } - if (m_Paragon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) { switch (Utility.Random(4)) { @@ -3095,9 +3223,9 @@ namespace Server.Mobiles } } - if (!Summoned && !NoKillAwards && !m_HasGeneratedLoot) + if (!Summoned && !NoKillAwards && !_hasGeneratedLoot) { - m_HasGeneratedLoot = true; + _hasGeneratedLoot = true; GenerateLoot(false); } @@ -3123,12 +3251,14 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - public int ComputeBonusDamage(in ValueLinkList list, Mobile m) + public int ComputeBonusDamage(List list, Mobile m) { var bonus = 0; - foreach (var de in list.ByDescending()) + for (var i = list.Count - 1; i >= 0; --i) { + var de = list[i]; + if (de.Damager == m || de.Damager is not BaseCreature bc) { continue; @@ -3165,15 +3295,26 @@ namespace Server.Mobiles Combatant is PlayerMobile || Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile; - // Iterates most recent first, matching the previous reverse-indexed loop. The list is - // already pruned of expired entries by the Mobile.DamageEntries getter. - public static List GetLootingRights(in ValueLinkList damageEntries, int hitsMax) + public static List GetLootingRights(List damageEntries, int hitsMax) { var rights = new List(); DamageStore firstDamager = null; - foreach (var de in damageEntries.ByDescending()) + for (var i = damageEntries.Count - 1; i >= 0; --i) { + if (i >= damageEntries.Count) + { + continue; + } + + var de = damageEntries[i]; + + if (de.HasExpired) + { + damageEntries.RemoveAt(i); + continue; + } + var damage = de.DamageGiven; var respList = de.Responsible; @@ -3289,7 +3430,7 @@ namespace Server.Mobiles MondainsLegacy.GiveArtifactTo(mob); } } - else if (m_Paragon) + else if (_isParagon) { if (Paragon.CheckArtifactChance(mob, this)) { @@ -3298,9 +3439,6 @@ namespace Server.Mobiles } } - [GeneratedEvent(nameof(CreatureDeathEvent))] - public static partial void CreatureDeathEvent(BaseCreature bc); - public override void OnDeath(Container c) { if (IsBonded) @@ -3323,7 +3461,6 @@ namespace Server.Mobiles ProcessDelta(); SendIncomingPacket(); - // TODO: This can be done in Parallel if there are lots of them. var aggressors = Aggressors; for (var i = 0; i < aggressors.Count; ++i) @@ -3363,7 +3500,7 @@ namespace Server.Mobiles OwnerAbandonTime = DateTime.MinValue; } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); CheckStatTimers(); return; @@ -3397,7 +3534,6 @@ namespace Server.Mobiles if (ds.m_Mobile == killer) { - // If the titles system gets feature flagged, it will be supported titles.Add(ds.m_Mobile); fame.Add(totalFame); karma.Add(totalKarma); @@ -3493,20 +3629,17 @@ namespace Server.Mobiles c.Delete(); } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); } - [GeneratedEvent(nameof(CreatureDeletedEvent))] - public static partial void CreatureDeletedEvent(BaseCreature bc); - public override void OnDelete() { - CreatureDeletedEvent(this); + CreatureEvents.CreatureDeletedEvent(this); - var m = m_ControlMaster; + var m = _controlMaster; SetControlMaster(null); + SummonMaster = null; // uncontrolled summons have no control master to clear through - SummonMaster = null; ReceivedHonorContext?.Cancel(); base.OnDelete(); @@ -3552,6 +3685,11 @@ namespace Server.Mobiles Controlled = false; ControlTarget = null; ControlOrder = OrderType.None; + + if (_summoned) + { + SummonMaster = null; + } } else { @@ -3576,11 +3714,16 @@ namespace Server.Mobiles ControlTarget = null; ControlOrder = OrderType.Come; - - if (m_DeleteTimer != null) + if (_summoned) { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; + SummonMaster = m; + } + + + if (_pendingDeleteTimer != null) + { + _pendingDeleteTimer.Stop(); + _pendingDeleteTimer = null; } } @@ -3774,7 +3917,7 @@ namespace Server.Mobiles { // *rummages through a corpse and takes an item* PublicOverheadMessage(MessageType.Emote, 0x3B2, 1008086); - // TODO: Instancing of Rummaged stuff. + //TODO Instance rummaged loot return true; } } @@ -3795,14 +3938,14 @@ namespace Server.Mobiles return BardMaster; } - if (_controlled && m_ControlMaster != null) + if (_controlled && _controlMaster != null) { - return m_ControlMaster; + return _controlMaster; } - if (_summoned && m_SummonMaster != null) + if (_summoned && _summonMaster != null) { - return m_SummonMaster; + return _summonMaster; } return base.GetDamageMaster(damagee); @@ -4074,17 +4217,17 @@ namespace Server.Mobiles if (this is not BaseEscortable && !Summoned && !Deleted && !IsStabled) { StopDeleteTimer(); - m_DeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); } } public void StopDeleteTimer() { - if (m_DeleteTimer != null) + if (_pendingDeleteTimer != null) { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; + _pendingDeleteTimer.Stop(); + _pendingDeleteTimer = null; } } @@ -4120,11 +4263,7 @@ namespace Server.Mobiles } } - /* - Solen Style, override me for other mobiles/items: - kappa+acidslime, grizzles+whatever, etc. - */ - + // Solen-style acid; override for other harmful drops (kappa slime, etc.). public virtual Item NewHarmfulItem() => new Acid(TimeSpan.FromSeconds(10), 30, 30); public virtual void StopFlee() @@ -4165,7 +4304,7 @@ namespace Server.Mobiles public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m); public virtual bool IsFriend(Mobile m) => - OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && m_Team == c.m_Team + OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team && (_summoned || _controlled) == (c._summoned || c._controlled); public virtual Allegiance GetFactionAllegiance(Mobile mob) @@ -4357,16 +4496,15 @@ namespace Server.Mobiles if (Core.SE) { - m_Loyalty = MaxLoyalty; + _loyalty = MaxLoyalty; } - else if (m_Loyalty < MaxLoyalty) + else if (_loyalty < MaxLoyalty) { - // Calculate the loyalty increase var loyaltyIncrease = Utility.CoinFlips(amount, MaxLoyaltyIncrease) * 10; - if (loyaltyIncrease > 0) // Only update if there's an actual increase + if (loyaltyIncrease > 0) { - m_Loyalty = Math.Min(MaxLoyalty, m_Loyalty + loyaltyIncrease); + _loyalty = Math.Min(MaxLoyalty, _loyalty + loyaltyIncrease); SayTo(from, 502060); // Your pet looks happier. } } @@ -4382,7 +4520,7 @@ namespace Server.Mobiles if (IsBondable && !IsBonded) { - var master = m_ControlMaster; + var master = _controlMaster; if (master != null && master == from) // So friends can't start the bonding process { @@ -4569,7 +4707,6 @@ namespace Server.Mobiles } } - /* Sanity check */ if (baseToSet > theirSkill.CapFixedPoint || m.Skills.Total - theirSkill.BaseFixedPoint + baseToSet > m.Skills.Cap) { @@ -4736,14 +4873,14 @@ namespace Server.Mobiles public void SetDamage(int val) { - m_DamageMin = val; - m_DamageMax = val; + _damageMin = val; + _damageMax = val; } public void SetDamage(int min, int max) { - m_DamageMin = min; - m_DamageMax = max; + _damageMin = min; + _damageMax = max; } public void SetHits(int val) @@ -4877,27 +5014,27 @@ namespace Server.Mobiles { case ResistanceType.Physical: { - m_PhysicalResistance = val; + _physicalResistanceSeed = val; break; } case ResistanceType.Fire: { - m_FireResistance = val; + _fireResistSeed = val; break; } case ResistanceType.Cold: { - m_ColdResistance = val; + _coldResistSeed = val; break; } case ResistanceType.Poison: { - m_PoisonResistance = val; + _poisonResistSeed = val; break; } case ResistanceType.Energy: { - m_EnergyResistance = val; + _energyResistSeed = val; break; } } @@ -5033,23 +5170,55 @@ namespace Server.Mobiles } } - // If this needs to be serialized, recommend creating a hash or registry id. Don't serialize strings. - public virtual SpeedLevel SpeedClass => SpeedLevel.None; + // A type's constant bucket; runtime state changes assign SpeedClass instead. + public virtual SpeedLevel DefaultSpeedClass => SpeedLevel.None; + + // Resolved once per creature; serialization consults the table four times per mob + // per save (and again on elided loads), so the dictionary walk must not repeat. + private NPCSpeeds.SpeedClassEntry _speedEntry; + + private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(_speedClass); + + private SpeedLevel? _resolvedDefaultSpeedClass; + + // The bucket a fresh spawn of this type resolves to (construction-time only). + private SpeedLevel ResolvedDefaultSpeedClass => + _resolvedDefaultSpeedClass ??= NPCSpeeds.ResolveDefaultLevel(this); public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) { - NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); + var entry = SpeedEntry; + + if (entry == null) + { + if (_speedClass == SpeedLevel.None) + { + // A custom creature is its own reference. + activeSpeed = _activeSpeed; + passiveSpeed = _passiveSpeed; + return; + } + + throw new InvalidOperationException( + $"{GetType()} names bucket {_speedClass} but the table has no entry - is {"Data/npc-speeds.json"} missing?" + ); + } + + activeSpeed = entry.ActiveSpeed; + passiveSpeed = entry.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates an unloaded table. public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) { - NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + var entry = SpeedEntry; + + activeMoveSpeed = entry?.ActiveMoveSpeed ?? 0; + passiveMoveSpeed = entry?.PassiveMoveSpeed ?? 0; } - // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds - // still match what it would spawn with today was never hand-tuned: adopt today's - // move values so existing worlds (and pets) pick up npc-speeds pacing without a - // respawn. Tuned creatures keep movement inheriting their think clock. + // Pre-v22 saves carry no movement clock. Think speeds matching today's GetSpeeds + // mean never hand-tuned: adopt today's move values; tuned creatures keep inheriting. internal void MigrateMoveSpeeds() { GetSpeeds(out var activeSpeed, out var passiveSpeed); @@ -5098,7 +5267,7 @@ namespace Server.Mobiles GenerateLoot(); - if (m_Paragon) + if (_isParagon) { if (Fame < 1250) { @@ -5498,8 +5667,6 @@ namespace Server.Mobiles var onSelf = patient == this; - // DoBeneficial( patient ); - RevealingAction(); if (!onSelf) @@ -5542,7 +5709,7 @@ namespace Server.Mobiles { patient.SendLocalizedMessage(1010059); // You have been cured of all poisons. - CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); // TODO: Verify formula + CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); //TODO Verify formula CheckSkill(SkillName.Anatomy, 0.0, 100.0); } } @@ -5741,7 +5908,6 @@ namespace Server.Mobiles using var toRelease = PooledRefQueue.Create(); - // added array for wild creatures in house regions to be removed using var toRemove = PooledRefQueue.Create(); foreach (var m in World.Mobiles.Values) @@ -5799,7 +5965,7 @@ namespace Server.Mobiles } } - // added lines to check if a wild creature in a house region has to be removed or not + // Wild creatures squatting in houses are removed outright. if (!c.Controlled && !c.IsStabled && (c.Region.IsPartOf() && c.CanBeDamaged() || c.RemoveIfUntamed && c.Spawner == null)) { @@ -5821,12 +5987,13 @@ namespace Server.Mobiles var c = toRelease.Dequeue(); c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! - c.Loyalty = BaseCreature.MaxLoyalty; // Wonderfully Happy + c.Loyalty = BaseCreature.MaxLoyalty; c.IsBonded = false; c.BondingBegin = DateTime.MinValue; c.OwnerAbandonTime = DateTime.MinValue; c.ControlTarget = null; - // This will prevent no release of creatures left alone with AI disabled (and consequent bug of Followers) + // Release directly: a creature left alone with its AI disabled would + // otherwise never release and permanently hold its owner's follower slots. c.AIObject.DoOrderRelease(); c.DropBackpack(); } diff --git a/Projects/UOContent/Mobiles/CreatureEvents.cs b/Projects/UOContent/Mobiles/CreatureEvents.cs new file mode 100644 index 000000000..d1fdc9112 --- /dev/null +++ b/Projects/UOContent/Mobiles/CreatureEvents.cs @@ -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); +} diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index 46d939f3b..d3b119523 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature 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; Combatant = master.Combatant; diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index ce4f3526d..91367a5f0 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -108,7 +108,7 @@ namespace Server.Mobiles */ 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 diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs index 178436fb9..fca41ec25 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs @@ -154,7 +154,7 @@ namespace Server.Mobiles public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m); [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] - [OnEvent(nameof(CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] public static void StopEffect(Mobile m, bool message = false) { if (m_Table.Remove(m, out var timer)) diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index 8f5e908bb..91a649871 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -8,7 +8,7 @@ namespace Server.Mobiles; public enum SpeedLevel { - None, + None, // no bucket: the creature's own speeds are authoritative (custom) VerySlow, Slow, Medium, @@ -26,34 +26,29 @@ public static class NPCSpeeds public static int MinIdleSeconds { 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)) && - !_speedsByType.TryGetValue(bc.GetType(), out sp)) + if (bc.DefaultSpeedClass != SpeedLevel.None) { - sp = _speedsByLevel[SpeedLevel.Medium]; + return bc.DefaultSpeedClass; } - activeSpeed = sp.ActiveSpeed; - 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)) + if (_speedsByType.TryGetValue(bc.GetType(), out var sp)) { - activeMoveSpeed = 0; - passiveMoveSpeed = 0; - return; + return sp.Level; } - activeMoveSpeed = sp.ActiveMoveSpeed; - passiveMoveSpeed = sp.PassiveMoveSpeed; + return _speedsByLevel.ContainsKey(SpeedLevel.Medium) ? SpeedLevel.Medium : SpeedLevel.None; } + // 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) { _speedsByLevel[entry.Level] = entry; diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 83c0e4cca..d60a10aa7 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3553,7 +3553,6 @@ namespace Server.Mobiles pet.Internalize(); pet.SetControlMaster(null); - pet.SummonMaster = null; pet.IsStabled = true; pet.StabledBy = this; @@ -3601,12 +3600,6 @@ namespace Server.Mobiles if (Followers + pet.ControlSlots <= FollowersMax) { pet.SetControlMaster(this); - - if (pet.Summoned) - { - pet.SummonMaster = this; - } - pet.ControlTarget = this; pet.ControlOrder = OrderType.Follow; diff --git a/Projects/UOContent/Mobiles/Special/Harrower.cs b/Projects/UOContent/Mobiles/Special/Harrower.cs index 8d87943bc..ee3fa1873 100644 --- a/Projects/UOContent/Mobiles/Special/Harrower.cs +++ b/Projects/UOContent/Mobiles/Special/Harrower.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; -using Server.Collections; using Server.Engines.CannedEvil; using Server.Engines.Virtues; using Server.Items; diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs index 847209869..0fb347fd4 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs @@ -255,7 +255,6 @@ namespace Server.Mobiles pet.Internalize(); pet.SetControlMaster(null); - pet.SummonMaster = null; pet.IsStabled = true; pet.StabledBy = from; @@ -356,12 +355,6 @@ namespace Server.Mobiles private void DoClaim(Mobile from, BaseCreature pet) { pet.SetControlMaster(from); - - if (pet.Summoned) - { - pet.SummonMaster = from; - } - pet.ControlTarget = from; pet.ControlOrder = OrderType.Follow; diff --git a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs index 384e468b9..a6297837d 100644 --- a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs @@ -86,17 +86,11 @@ public static class IncomingPlayerPackets public static void HuePickerResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - reader.ReadInt16(); // Item ID + _ = reader.ReadInt16(); // Item ID var hue = Utility.ClipDyedHue(reader.ReadInt16() & 0x3FFF); - 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) { state.RemoveHuePicker(huePicker); @@ -293,18 +287,13 @@ public static class IncomingPlayerPackets public static void MenuResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - reader.ReadInt16(); // menu id + int menuID = reader.ReadInt16(); int index = reader.ReadInt16(); - reader.ReadInt16(); // item id - reader.ReadInt16(); // hue + int itemID = reader.ReadInt16(); + int hue = reader.ReadInt16(); index -= 1; // convert from 1-based to 0-based - if (state.Menus == null) - { - return; - } - for (var i = 0; i < state.Menus.Count; i++) { var menu = state.Menus[i]; diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index 776ec269d..b18c7fa18 100644 --- a/Projects/UOContent/Regions/BaseRegion.cs +++ b/Projects/UOContent/Regions/BaseRegion.cs @@ -113,7 +113,7 @@ public class BaseRegion : Region m_RectBuffer2.RemoveAt(k); var sz = rect.Start.Z; - var ez = rect.End.Z; + var ez = rect.End.X; if (l1 < l2) { diff --git a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs index c0f3c3ed0..578bdc482 100644 --- a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs +++ b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs @@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell // 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.PlayerDeletedEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void OnCurseEnds(Mobile m) => RemoveCurse(m); private class ExpireTimer : Timer diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 63788aa69..6d85b9d73 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,7 +238,10 @@ namespace Server.Mobiles 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 { diff --git a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs index 585d37284..6a5d4850a 100644 --- a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs +++ b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs @@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving Caster.Target = new SpellTarget(this, TargetFlags.Beneficial); } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] public static void OnDeathEvent(Mobile m) { diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 7310c104e..8edf70a72 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -29,9 +29,6 @@ description: > - `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default) - `Name = "text"` -> `public override string DefaultName => "text";` - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` -- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement -- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition -- `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 6e0902b64..ce7f18acf 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,18 +27,8 @@ description: > (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is - derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- - see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching - enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s - default, `ReacquireDelay`-only = oblivious) -- see § Target Acquisition -8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the - think cadence (player commands prod it; speed-ups reschedule it). Gate consequential - work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call - random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the - trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a - zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see - `dev-docs/content-patterns.md` § OnThink: the excess-call contract + AND clears move overrides, `SetMoveSpeed()` sets move only -- see + `dev-docs/content-patterns.md` § Creature Speeds ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 6205ec2d4..2c78fcd67 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs (combat decisions, target acquisition, spell timing). - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per 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 - independently of think ticks, so the two need not divide evenly. + only think speeds behaves as one clock. The properties read the raw override (`0` = + 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 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 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 30–45s — 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 diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index def2ecf48..c5029131c 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,88 +474,6 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha | `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` | | `get { return value; }` | `=> value;` expression-bodied | -## AI Movement: No `run` Argument - -RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in -combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the -client's per-step animation time, so ModernUO derives it from the creature's step pace -(`BaseAI.ShouldRun`) and the parameter is gone: - -```csharp -// RunUO -MoveTo(combatant, true, m_Mobile.RangeFight); -WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1); - -// ModernUO -MoveTo(combatant, Mobile.RangeFight); -WalkMobileRange(Mobile.ControlMaster, 1, 0, 1); -``` - -`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way. -To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged. -An isolated step (after the creature stood for at least a walk interval) goes out as a -walk regardless of pace — only a continuing cadence, or a pace faster than the run -interpolation, flags run. - -## Target Acquisition: `AcquireOnApproach` Is a Delay - -RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now -`AcquireOnApproachDelay`, a `TimeSpan` reaction-time gradient that applies to every -creature — enemy movement inside `AcquireOnApproachRange` schedules a scan within the -delay instead of waiting out the 10 s `ReacquireDelay` poll: - -```csharp -// RunUO -public override bool AcquireOnApproach => true; - -// ModernUO — Zero is the old instant behavior; larger values are dumber -public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; -``` - -`AcquireOnApproachRange` stays 10 for all creatures (reactive aggro is on-screen; the -periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The -acquired target comes from the normal FightMode-ranked scan, not from whichever mobile -happened to move. See `content-patterns.md` § Target Acquisition. - -## Damage Entries: Inline `ValueLinkList`, Not `List` - -RunUO's `Mobile.DamageEntries` was a `List` allocated for every mobile. -ModernUO keeps damage entries in an inline `ValueLinkList` 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 - -// 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 -``` - -What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and -passing the property where a `List` is expected. `.Count`, -`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and -`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList` -link fields; never set them yourself, and never call a `ValueLinkList` mutator on the -`ref readonly` property — it compiles against a copy and corrupts the node's link state. -Mutate only through `RegisterDamage` and `ClearDamageEntries`. - ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index b6c12bfe8..215dd3b06 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,13 +130,6 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same | | `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` | | `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters | -| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) | -| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | -| `PathFollower.Follow(run, range)` | `Follow(range)` | Same | -| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior | -| `m.DamageEntries` (`List`) | `m.DamageEntries` (`ref readonly ValueLinkList`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` | -| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | | -| `GetLootingRights(List, int)` | `GetLootingRights(in ValueLinkList, int)` | Callers passing `m.DamageEntries` compile unchanged | ## Networking