From be3a08513f8e68a9d957fe369b806a05c019ce75 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:14:38 -0700 Subject: [PATCH 01/20] fix: Fixes PlayerConstructed stacking/BODs (#2579) ### Summary * Removes player constructed as a requirement for BODs. * When two items stack and they don't match player constructed flags, the resulting stack loses the flag. --- .../Items/PlayerConstructedStackingTests.cs | 41 ------------------- Projects/Server/Items/Item.cs | 8 +++- .../UOContent/Engines/Bulk Orders/SmallBOD.cs | 6 +-- 3 files changed, 7 insertions(+), 48 deletions(-) diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs index 05595068a..955a87293 100644 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -24,26 +24,6 @@ public class PlayerConstructedStackingTests private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - [Fact] - public void CanStackWith_IsFalseWhenProvenanceDiffers() - { - var bought = MakeStack((Serial)0x1, 5, false); - var crafted = MakeStack((Serial)0x2, 5, true); - - try - { - // Both orders must fail. Whichever is the receiver decides the merged pile's flag, - // so allowing either one means the result is decided by drag direction. - Assert.False(bought.CanStackWith(crafted)); - Assert.False(crafted.CanStackWith(bought)); - } - finally - { - bought.Delete(); - crafted.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] @@ -63,27 +43,6 @@ public class PlayerConstructedStackingTests } } - [Fact] - public void StackWith_RefusesToMergeAcrossProvenance() - { - var bought = MakeStack((Serial)0x1, 5, false); - var crafted = MakeStack((Serial)0x2, 5, true); - - try - { - Assert.False(bought.StackWith(null, crafted, false)); - Assert.Equal(5, bought.Amount); - Assert.Equal(5, crafted.Amount); - Assert.False(bought.PlayerConstructed); - Assert.False(crafted.Deleted); - } - finally - { - bought.Delete(); - crafted.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 889be4619..dae64a3f5 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -2350,7 +2350,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert dropped.ItemID == ItemID && dropped.Hue == Hue && dropped.Name == Name && - dropped.PlayerConstructed == PlayerConstructed && dropped.Amount + Amount <= 60000 && dropped != this; @@ -2369,6 +2368,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; + if (PlayerConstructed != dropped.PlayerConstructed) + { + PlayerConstructed = false; + } + dropped.Delete(); if (playSound && from != null) @@ -3451,7 +3455,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert for (var i = 0; i < props.Length; i++) { var p = props[i]; - if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) + if (p.GetCustomAttribute(true) != null || !p.CanRead || !p.CanWrite) { continue; } diff --git a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs index 746da8687..6356780de 100644 --- a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs @@ -137,11 +137,7 @@ public abstract partial class SmallBOD : BaseBOD { var material = GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None); - if (!item.PlayerConstructed) - { - from.SendLocalizedMessage(1045169); // The item is not in the request. - } - else if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) + if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) { from.SendLocalizedMessage(1045168); // The item is not made from the requested ore. } From fd27b7a3c9e8705b88db0b8331673728a46bff98 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:22:04 -0700 Subject: [PATCH 02/20] chore: Simplify server requirements section in README (#2582) Removed unnecessary details about game logic and server requirements. --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index b8d49f544..01a368804 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,7 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc | Medium (50–200) | 4–8 | 8 GB | NVMe | | Large (200+) | 8+, high clock | 16 GB+ | NVMe | -Game logic is single-threaded, so **single-core clock speed matters more than core count**, and -**dedicated vCPU matters more than either** — burstable or shared plans throttle once credits run -out, which is the most common cause of unexplained lag spikes. Save size drives RAM more than -player count does. - -See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for the reasoning and tuning -options. +See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for more information. #### Development [![git](https://img.shields.io/badge/-git-F05032?logo=git&logoColor=F05032&labelColor=222222)](https://git-scm.com/downloads) From 971d7b6a778f55d94891f6d376b9f4dad923ff78 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:56:00 -0700 Subject: [PATCH 03/20] fix: stop items from insta-decaying when decay eligibility is restored without a move (#2583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A GM flipping `Movable` back on for a long-frozen item made it vanish within one scheduler tick. The setter registered the item with a deadline computed from its stale `LastMoved`, so `ProcessActiveQueue` deleted it almost immediately. The pre-#2311 save-time sweep had the same semantics, just hidden behind the save cadence. The same failure existed for `Visible` and `Spawner` transitions. `LastMoved` is deliberately left meaning actual movement — it feeds vendor inventory expiry and house moving-crate checks — so the fix does not rewrite it for state changes. ## Changes - **`DecayResetTime`** (CompactInfo-backed): the decay countdown runs from the later of `LastMoved` and this stamp. `RestartDecay()` stamps it only when the item can decay and the stamp extends the current deadline, so hot paths with a fresh `LastMoved` allocate nothing. - **`Movable`/`Visible`/`Spawner` setters** call `RestartDecay()` instead of registering a stale deadline. - **Region-refusal retry** in `DecayScheduler` uses `RestartDecay()` instead of rewriting `LastMoved`. - **Persistence**: the stamp survives save/load as a `WriteDeltaTime` delta under `SaveFlag.DecayReset` (to become `WriteAnchoredTime` once the save-time anchor is ported) (Item serialization v10), so a restart mid-window no longer deletes the item. - **`LastMoved` setter** drops a superseded stamp so the `CompactInfo` can collapse instead of being held (~40 bytes) forever. - **Raw `Map` setter** now counts as a move for parentless items: it stamps `LastMoved` and updates decay registration, closing the gap where an item moved out of `Map.Internal` via the setter never decayed. - **`LiftItemDupe`**: the remainder of a partially lifted *ground* stack was placed via raw `Location`/`Map` assignments and never enrolled for decay (lingering-trash leak since #2311) — now enrolled via the Map setter. Parented remainders get their map from `AddItem` (parent first, then map), so container splits never transit the scheduler. --- .../Tests/Items/DecayRegistrationTests.cs | 266 ++++++++++++++++++ Projects/Server/Items/DecayScheduler.cs | 4 +- Projects/Server/Items/Item.cs | 129 ++++++++- Projects/Server/Mobiles/Mobile.cs | 8 +- 4 files changed, 397 insertions(+), 10 deletions(-) diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 0e48977ef..59d736c6f 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -208,6 +208,272 @@ public class DecayRegistrationTests item.Delete(); } + // Unfreezing an item with a stale LastMoved must grant a fresh decay window, + // not delete it on the next tick. + [Fact] + public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Movable = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // Same transition through the Visible setter: unhiding a long-hidden item. + [Fact] + public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Visible = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // A refusal restarts the countdown without rewriting LastMoved. + [Fact] + public void RefusedDecay_DoesNotRewriteLastMoved() + { + var start = Core._now; + + try + { + var item = new RefusesDecayItem(); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; + + AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. + [Fact] + public void FreshDecayWindow_SurvivesSerialization() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + var expected = item.ScheduledDecayTime; + + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); + + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); + + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); + + item.Delete(); + copy.Delete(); + } + finally + { + Core._now = start; + } + } + + // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. + [Fact] + public void MovingAnItem_ClearsASupersededDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + Core._now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Losing decay eligibility makes the stamp meaningless; it must be dropped so the + // CompactInfo is not held for as long as the item stays ineligible. + [Fact] + public void ItemBecomingIneligible_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + item.Movable = false; + + Assert.Equal(default, item.DecayResetTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) + // must also drop the stamp. + [Fact] + public void StampedItemAddedToContainer_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var pack = new Container(0xE75); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + pack.AddItem(item); + + Assert.Equal(default, item.DecayResetTime); + + pack.Delete(); + } + finally + { + Core._now = start; + } + } + + // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must + // enroll an untracked item for decay. + [Fact] + public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() + { + var item = new Item(0x1234); + Assert.False(DecayScheduler.IsRegistered(item)); + + item.Map = Map.Felucca; + + Assert.True(item.CanDecay()); + Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked."); + + item.Delete(); + } + + // LiftItemDupe places the remainder of a partially lifted ground stack via raw + // Location/Map assignments, with no MoveToWorld fallback: it must still be tracked. + [Fact] + public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() + { + var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); + + var remainder = Mobile.LiftItemDupe(stack, 3); + + Assert.NotNull(remainder); + Assert.Equal(7, remainder.Amount); + Assert.Null(remainder.Parent); + Assert.Equal(Map.Felucca, remainder.Map); + Assert.True( + DecayScheduler.IsRegistered(remainder), + "The remainder of a partially lifted ground stack must be tracked for decay." + ); + + stack.Delete(); + remainder.Delete(); + } + // Dropping into a container must untrack; taking it back out to the ground must re-track. [Fact] public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() diff --git a/Projects/Server/Items/DecayScheduler.cs b/Projects/Server/Items/DecayScheduler.cs index 694d9b6b2..70dc1ef27 100644 --- a/Projects/Server/Items/DecayScheduler.cs +++ b/Projects/Server/Items/DecayScheduler.cs @@ -311,7 +311,7 @@ public class DecayScheduler : Timer if (timeUntilDecay > _bucketInterval) { - // Item was moved (SetLastMoved called) - re-bucket or move to overflow + // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow if (timeUntilDecay > _totalBucketSpan) { // Extended beyond total span - move to overflow @@ -429,7 +429,7 @@ public class DecayScheduler : Timer { // Refused by the region. Restart the clock rather than dropping the item, which has // already left the queue; re-registering as-is would spin on a due time in the past. - item.SetLastMoved(); + item.RestartDecay(); } } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index dae64a3f5..a2b63a821 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -335,7 +335,25 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - public DateTime LastMoved { get; set; } + private DateTime _lastMoved; + + public DateTime LastMoved + { + get => _lastMoved; + set + { + _lastMoved = value; + + // A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse. + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -373,7 +391,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -389,7 +407,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -845,7 +863,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(9); // version + writer.Write(10); // version var flags = SaveFlag.None; @@ -955,6 +973,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { flags |= SaveFlag.SavedFlags; } + + if (info.m_DecayReset > LastMoved) + { + flags |= SaveFlag.DecayReset; + } } if (info == null || info.m_Weight < 0) @@ -1001,6 +1024,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); /* end */ + if (GetSaveFlag(flags, SaveFlag.DecayReset)) + { + //TODO Use WriteAnchoredTime once the save-time anchor is ported + writer.WriteDeltaTime(info.m_DecayReset); + } + if (GetSaveFlag(flags, SaveFlag.Direction)) { writer.Write((byte)m_Direction); @@ -1318,6 +1347,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert OnMapChange(); + if (m_Parent == null) + { + // A map change is a move; nothing else updates decay registration for a raw Map change. + SetLastMoved(); + } + if (old == null || old == Map.Internal) { InvalidateProperties(); @@ -1548,7 +1583,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -1742,6 +1777,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert || info.m_HeldBy != null || info.m_BlessedFor != null || info.m_Spawner != null + || info.m_DecayReset != default || info.m_TempFlags != 0 || info.m_SavedFlags != 0 || info.m_Weight >= 0; @@ -2326,7 +2362,64 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual bool OnDecay() => CanDecay() && Region.Find(Location, Map).OnDecay(this); - public DateTime ScheduledDecayTime => LastMoved + DecayTime; + public DateTime ScheduledDecayTime + { + get + { + var reset = DecayResetTime; + var lastMoved = LastMoved; + + return (reset > lastMoved ? reset : lastMoved) + DecayTime; + } + } + + /// + /// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it. + /// The decay countdown runs from the later of this and . + /// + public DateTime DecayResetTime + { + get => LookupCompactInfo()?.m_DecayReset ?? default; + private set + { + if (value == default) + { + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + else + { + AcquireCompactInfo().m_DecayReset = value; + } + } + } + + /// + /// Restarts the decay countdown without touching : call when decay + /// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a + /// stale would otherwise decay the item on the next tick. + /// Stamps only when that extends the current deadline, then + /// updates the scheduler registration. + /// + public void RestartDecay() + { + if (CanDecay()) + { + var now = Core.Now; + + if (ScheduledDecayTime < now + DecayTime) + { + DecayResetTime = now; + } + } + + UpdateDecayRegistration(); + } public void UpdateDecayRegistration() { @@ -2336,6 +2429,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { DecayScheduler.Register(this); } + else + { + // No countdown to anchor while ineligible; drop the stamp so the CompactInfo + // can collapse. Re-eligibility always re-anchors. + DecayResetTime = default; + } } public void SetLastMoved() @@ -2673,6 +2772,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { + case 10: case 9: case 8: case 7: @@ -2698,6 +2798,18 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } } + if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) + { + var reset = reader.ReadDeltaTime(); + + // LastMoved is stored at whole-minute precision; keep the stamp only + // while it still extends the deadline. + if (reset > LastMoved) + { + DecayResetTime = reset; + } + } + if (GetSaveFlag(flags, SaveFlag.Direction)) { m_Direction = (Direction)reader.ReadByte(); @@ -4361,6 +4473,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public ISpawner m_Spawner; + public DateTime m_DecayReset; + public int m_TempFlags; public double m_Weight = -1; @@ -4399,6 +4513,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert IntWeight = 0x01000000, SavedFlags = 0x02000000, NullWeight = 0x04000000, - PlayerConstructed = 0x08000000 + PlayerConstructed = 0x08000000, + DecayReset = 0x10000000 } } diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 6755c244b..0e88449e3 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -5250,7 +5250,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; - item.Map = oldItem.Map; + + // A parented remainder gets its map from AddItem (parent first, then map), keeping the + // split off the decay scheduler; a ground remainder is placed and enrolled here. + if (oldItem.Parent == null) + { + item.Map = oldItem.Map; + } oldItem.OnAfterDuped(item); From 541dbc5ac5d45a8601eae765d0192163f4393f0e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:48:53 -0700 Subject: [PATCH 04/20] feat: Bumps dependencies. Introduces Serialization Generator v3 (#2584) ### Summary * Upgrades Serialization Generator to v3. This contains numerous bug fixes and a significant performance improvement. * Bumps other dependencies. --- .config/dotnet-tools.json | 2 +- Directory.Build.props | 2 +- Projects/BuildTool/BuildTool.csproj | 1 - Projects/Server.Tests/Server.Tests.csproj | 5 ++--- Projects/Server/Server.csproj | 7 +++---- Projects/UOContent.Tests/UOContent.Tests.csproj | 5 ++--- Projects/UOContent/UOContent.csproj | 9 ++++----- 7 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index da66d8d0c..8b18d54e8 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "2.14.3", + "version": "3.0.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/Directory.Build.props b/Directory.Build.props index 30bf85099..c832f1006 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -71,7 +71,7 @@ $(DefineConstants);EVENT_LOOP_PROFILING - + diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj index b3f37cf7a..983000240 100644 --- a/Projects/BuildTool/BuildTool.csproj +++ b/Projects/BuildTool/BuildTool.csproj @@ -17,6 +17,5 @@ - diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 1be789258..4bae245cb 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,17 +5,16 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 49d8d496a..522701b78 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,11 +37,10 @@ - + - - - + + diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 712246f72..1c73f6eb4 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,9 +4,9 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -15,7 +15,6 @@ - diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index d9961f38d..5c7b74214 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -41,18 +41,17 @@ false - + - + - - - + + From 126a10ce5340117ef8b020c9d7dbdc782ef47e44 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:59:39 -0700 Subject: [PATCH 05/20] feat: anchored-time infrastructure with a save-start anchor in idx v5 (#2585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The save-stability infrastructure consumed by generator v3's `[AnchoredDateTime]`: anchored timestamps are written as **absolute values** and re-based once at load by the elapsed time since the save started — so downtime doesn't age them, and an unchanged entity serializes to identical bytes (the prerequisite for replacing delta-time encodings, which rewrite every entity on every save). ## Design - **`WriteAnchoredTime` / `ReadAnchoredTime`** on `IGenericWriter`/`IGenericReader`. The read side applies the reader's `AnchoredTimeShift`; `Min/MaxValue` sentinels pass through unshifted, and shifts saturate instead of overflowing. - **`World.SaveStartTime`** is stamped the moment the world freezes for a snapshot — one anchor for the entire save, no per-persistence skew. - **idx v5**: the anchor ticks sit in the header right after the version. The anchor travels with the file it re-anchors, so a single idx+bin pair restored from a backup is self-describing, and anchor presence is guaranteed by the same version gate as the record format — there is no separate anchor file to lose. - **The shift rides the reader instance** (`BufferReader`, `UnmanagedDataReader`, `BinaryFileReader` delegating), not a static — parallel per-persistence loads and ad-hoc restores each see their own file's anchor. idx v4 and older read with a zero shift. ## Scope Behavior-neutral: nothing serializes anchored values yet (`Item.DecayResetTime` and the `[DeltaDateTime]` field migrations come separately, with their own version bumps). Saves written from this branch are idx v5; loading v4/v3 saves is unchanged and remains pinned by the existing hand-written-header tests. ## Testing - Unit round-trips: exact with zero shift, shifted read, sentinel passthrough, saturation, Local→UTC normalization. - End-to-end through the real worker/segment-log pipeline: an anchored timestamp re-bases across a simulated two-hour downtime via the idx v5 header. - Full suites green: Server.Tests 835/835, UOContent.Tests 708/708 (including the existing v4/v3 idx loading tests). --- .../Tests/Serialization/AnchoredTimeTests.cs | 189 ++++++++++++++++++ .../Server/Serialization/BinaryFileReader.cs | 6 + Projects/Server/Serialization/BufferReader.cs | 2 + Projects/Server/Serialization/BufferWriter.cs | 15 ++ .../Serialization/GenericEntityPersistence.cs | 28 ++- .../Server/Serialization/IGenericReader.cs | 31 +++ .../Server/Serialization/IGenericWriter.cs | 1 + .../Serialization/UnmanagedDataReader.cs | 2 + Projects/Server/World/World.cs | 10 + 9 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs new file mode 100644 index 000000000..a99b9850c --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Server.Tests; + +public class AnchoredTimeTests +{ + private static (BufferWriter Writer, Func Read) CreateRoundTrip() + { + var writer = new BufferWriter(new byte[64], true); + return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift }); + } + + [Fact] + public void AnchoredTime_RoundTripsExactly_WithZeroShift() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_AppliesShiftOnRead() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + var shift = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value + shift, read(shift).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SentinelsPassThroughUnshifted() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MinValue); + writer.WriteAnchoredTime(DateTime.MaxValue); + + var reader = read(TimeSpan.FromDays(2)); + Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime()); + Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SaturatesInsteadOfOverflowing() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1)); + + Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_NormalizesLocalKindOnWrite() + { + var (writer, read) = CreateRoundTrip(); + var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local); + + writer.WriteAnchoredTime(local); + + Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); + } +} + +internal class AnchoredEntity : ISerializable +{ + public AnchoredEntity(Serial serial) => Serial = serial; + + public Serial Serial { get; } + public DateTime Created { get; set; } = DateTime.UtcNow; + public bool Deleted => false; + + public DateTime LastRested { get; set; } + + public void Delete() + { + } + + public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested); + + public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime(); +} + +[Collection("Sequential Server Tests")] +public class AnchoredTimePersistenceTests +{ + private class AnchoredPersistence : GenericEntityPersistence + { + public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF) + { + } + } + + /// + /// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps + /// by the elapsed time since the save started, so downtime does not age them. + /// + [Fact] + public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() + { + var previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + + var source = new SerializationChunkSource(); + var workers = new SerializationThreadWorker[2]; + for (var i = 0; i < workers.Length; i++) + { + workers[i] = new SerializationThreadWorker(i, source); + workers[i].AllocateHeap(); + } + + var previousWorkers = World._threadWorkers; + World._threadWorkers = workers; + + var previousSaveStart = World.SaveStartTime; + + var persistence = new AnchoredPersistence(2100); + AnchoredPersistence loaded = null; + + var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + try + { + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); + + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; + + foreach (var worker in workers) + { + worker.Wake(); + } + + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + source.Flush(); + foreach (var worker in workers) + { + worker.Sleep(); + } + + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); + + loaded = new AnchoredPersistence(2101); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; + + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); + } + finally + { + World.SaveStartTime = previousSaveStart; + persistence.Unregister(); + loaded?.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + + World._threadWorkers = previousWorkers; + AssemblyHandler.Assemblies = previousAssemblies; + Directory.Delete(dir, true); + } + } +} diff --git a/Projects/Server/Serialization/BinaryFileReader.cs b/Projects/Server/Serialization/BinaryFileReader.cs index ec76516aa..f8b51c182 100644 --- a/Projects/Server/Serialization/BinaryFileReader.cs +++ b/Projects/Server/Serialization/BinaryFileReader.cs @@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader /// public long Position => _reader.Position; + public TimeSpan AnchoredTimeShift + { + get => _reader.AnchoredTimeShift; + set => _reader.AnchoredTimeShift = value; + } + public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index 846e9e5dc..bd37f4ff6 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -37,6 +37,8 @@ public class BufferReader : IGenericReader public long Position => _position; public long BufferSize => _buffer.Length; + public TimeSpan AnchoredTimeShift { get; set; } + public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index b811d68cb..68fd971ba 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -407,6 +407,21 @@ public class BufferWriter : IGenericWriter Write(value.Ticks - DateTime.UtcNow.Ticks); } + /// + /// Writes the absolute value; re-bases it + /// by the elapsed time since the save started, so downtime does not age it and an + /// unchanged value serializes to identical bytes. + /// + public void WriteAnchoredTime(DateTime value) + { + if (value.Kind == DateTimeKind.Local) + { + value = value.ToUniversalTime(); + } + + Write(value.Ticks); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(IPAddress value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index a49f213eb..f27b22740 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -114,9 +114,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer using var binFs = new FileStream( Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024 ); - // v4 records are fixed-width 26 bytes; the header carries the type table - // (name lengths vary — 64 bytes per entry is a staging hint, not a contract). - var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; + // v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor + // and the type table (name lengths vary — 64 bytes per entry is a staging hint, not + // a contract). + var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; @@ -142,7 +143,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer binPosition += _selfLength; } - idx.Write(4); // Version + idx.Write(5); // Version + + // One anchor for the whole save: the world is frozen from the moment it is stamped. + idx.Write(World.SaveStartTime.Ticks); // The type table is fully known at freeze (AddEntity diverts to the pending // queues while saving) and is written before the records so the loader can @@ -494,6 +498,14 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var version = dataReader.ReadInt(); + if (version >= 5) + { + // Re-base anchored timestamps by the elapsed time since the save started. + var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); + var shift = Core.Now - anchor; + _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; + } + if (version >= 4) { DeserializeIndexesV4(dataReader, entities); @@ -660,6 +672,9 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private static List _toDelete; + // From the loaded idx (v5+); zero when the save predates the anchor. + private TimeSpan _anchoredTimeShift; + private unsafe void InternalDeserialize(string filePath, int index, Dictionary typesDb) { using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open); @@ -667,7 +682,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + AnchoredTimeShift = _anchoredTimeShift + }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index 0244a5166..4821a708b 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -52,6 +52,37 @@ public interface IGenericReader var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc) }; } + + /// + /// Elapsed time between the loaded save starting and this load, applied by + /// . Zero when the source carries no anchor. + /// + TimeSpan AnchoredTimeShift => TimeSpan.Zero; + + DateTime ReadAnchoredTime() + { + var value = ReadDateTime(); + + if (value == DateTime.MinValue || value == DateTime.MaxValue) + { + return value; + } + + var shift = AnchoredTimeShift; + if (shift == TimeSpan.Zero) + { + return value; + } + + var ticks = value.Ticks + shift.Ticks; + + if (ticks >= DateTime.MaxValue.Ticks) + { + return DateTime.MaxValue; + } + + return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc); + } decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]); int ReadEncodedInt() { diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 9c9563139..22579ab1a 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -41,6 +41,7 @@ public interface IGenericWriter void WriteEncodedInt(int value); void Write(DateTime value); void WriteDeltaTime(DateTime value); + void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); void Write(Point3D value); diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index 7c8b5da1e..aa3916ee9 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader /// public long Position { get; private set; } + public TimeSpan AnchoredTimeShift { get; set; } + /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 2c9e6d216..4111ef0d1 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -93,6 +93,12 @@ public static class World public static string SavePath { get; private set; } public static WorldState WorldState { get; private set; } public static bool Saving => WorldState == WorldState.Saving; + + /// + /// UTC time the current or most recent world save started. Written into save indexes so + /// anchored timestamps can be re-based by the downtime at load. + /// + public static DateTime SaveStartTime { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -287,6 +293,10 @@ public static class World WorldState = WorldState.Saving; + // The world is frozen from here: one anchor for the whole save. Written into save + // indexes so anchored timestamps can be re-based by the downtime at load. + SaveStartTime = Core.Now; + Broadcast(0x35, true, "The world is saving, please wait."); logger.Information("Saving world"); From 73f9688083300b883be0fb68052628e1ddb4658f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:54:02 -0700 Subject: [PATCH 06/20] feat: adopt serialization generator v4 (field-side linkage, anchored timers) (#2586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adopts ModernUO.Serialization 4.0.0 across the engine. Three commits, reviewable independently: 1. **Package + tool bump to 4.0.0** (`Server.csproj`, `UOContent.csproj`, `dotnet-tools.json`). 2. **Timers → `[DeserializeTimer]`** — the 8 drifting timers (BaseLight, TreasureMapChest, MarkContainer, FillableContainer, DeathRobe, DecayedCorpse, Corpse, BaseEscortable) now store their next tick as **anchored time**: server downtime no longer consumes the remaining delay, and idle-world saves are byte-stable. This changes their wire format, so each class bumps its serialization version with a `MigrateFrom` that replays the old delta-time read through the migration schema (the new `vN.json` files carry `@AnchoredTimer`; the old ones keep `@TimerDrift`, which the generator reads forever). The 2 wall-clock timers (Aquarium, FountainOfLife) keep their exact format via `wallClock: true` — no bump. Restart methods drop their `TimeSpan.MinValue` sentinel checks: v4 invokes them **only when a timer was actually running at save**. 3. **Linkage → field-side declarations** — 175 conversions across 25 files: `[SerializableFieldSaveFlag(order)]`/`[SerializableFieldDefault(order)]` become `[SaveFlag(nameof(...), nameof(...))]` on the field, and `[SerializableFieldChanged(order)]` becomes the `fieldChanged:` argument of `[SerializableField]`. **Wire-neutral: zero migration schemas changed.** ## Verification - Solution builds with **0 errors, 0 warnings**; all three 4.0.0 packages verified indexed on nuget.org (no local feed needed). - **835 + 708 tests green.** - Generated output inspected: old-version content structs replay `ReadDeltaTime` (e.g. `V3Content.DecayTimerNext = reader.ReadDeltaTime()`), current versions write/read anchored time with the gated restart, and the wall-clock classes emit byte-identical `Write`/`ReadDateTime` framing. - Schema tool run is committed (CI's `git diff --exit-code` schema check passes): exactly the 8 expected new `vN.json` files, nothing else touched. - The conversion was scripted with a class-scoped resolver (order → same-class `[SerializableField(order)]`/`[SerializableProperty(order)]`); it planned 175/175 with zero ambiguities before applying. ## Notes - New `MigrateFrom`s use the content structs' provided `XxxDelay` property, matching the pre-existing idiom in Corpse's and TreasureMapChest's older migrations. - Follow-up candidate (separate PR, wire-neutral, any time): fold the ~150 eligible hand-written `[SerializableProperty]` setters (clamps, post-change side effects) down to `[SerializableField]` with `allowFieldChange`/`fieldChanged` hooks. --- .config/dotnet-tools.json | 2 +- Projects/Server/Items/Container.cs | 11 +- Projects/Server/Mobiles/Mods/ResistanceMod.cs | 6 +- Projects/Server/Mobiles/Mods/SkillMod.cs | 14 +- Projects/Server/Server.csproj | 4 +- .../Engines/Bulk Orders/Books/BOBFilter.cs | 8 +- .../CannedEvil/ChampionTitleContext.cs | 18 +-- .../Engines/ConPVP/Games/BombingRun.cs | 3 +- .../Engines/ConPVP/Games/KingOfTheHill.cs | 3 +- .../UOContent/Engines/Plants/PlantItem.cs | 12 +- .../UOContent/Engines/Plants/PlantSystem.cs | 40 +++-- .../UOContent/Engines/Spawners/BaseSpawner.cs | 23 ++- .../UOContent/Engines/Spawners/Spawner.cs | 4 +- .../Engines/Virtues/VirtueContext.cs | 26 ++-- Projects/UOContent/Items/Aquarium/Aquarium.cs | 2 +- Projects/UOContent/Items/Armor/BaseArmor.cs | 60 +++----- .../Items/Armor/Glasses/ElvenGlasses.cs | 3 +- Projects/UOContent/Items/Books/BaseBook.cs | 11 +- .../UOContent/Items/Clothing/BaseClothing.cs | 24 ++- .../UOContent/Items/Clothing/OuterTorso.cs | 13 +- Projects/UOContent/Items/Clothing/Shoes.cs | 3 +- .../Fillable Containers/FillableContainer.cs | 15 +- .../Items/Containers/MarkContainer.cs | 18 ++- .../Items/Containers/TreasureMapChest.cs | 19 ++- Projects/UOContent/Items/Lights/BaseLight.cs | 18 ++- .../UOContent/Items/Misc/Corpses/Corpse.cs | 30 +++- .../Items/Misc/Corpses/DecayedCorpse.cs | 13 +- .../UOContent/Items/Quivers/BaseQuiver.cs | 16 +- .../Items/Skill Items/Magical/Runebook.cs | 8 +- .../8th Anniversary Items/FountainOfLife.cs | 2 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 35 ++--- .../UOContent/Items/Weapons/BaseWeapon.cs | 78 ++++------ .../Migrations/Server.Items.BaseLight.v2.json | 43 ++++++ .../Migrations/Server.Items.Corpse.v18.json | 137 ++++++++++++++++++ .../Migrations/Server.Items.DeathRobe.v4.json | 14 ++ .../Server.Items.DecayedCorpse.v3.json | 14 ++ .../Server.Items.FillableContainer.v3.json | 19 +++ .../Server.Items.MarkContainer.v1.json | 46 ++++++ .../Server.Items.TreasureMapChest.v4.json | 57 ++++++++ .../Server.Mobiles.BaseEscortable.v3.json | 43 ++++++ .../Mobiles/Animals/Mounts/Ethereals.cs | 8 +- .../Mobiles/Townfolk/BaseEscortable.cs | 18 ++- Projects/UOContent/UOContent.csproj | 4 +- 43 files changed, 667 insertions(+), 278 deletions(-) create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Items.Corpse.v18.json create mode 100644 Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json create mode 100644 Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json create mode 100644 Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json create mode 100644 Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8b18d54e8..f9f5fbe15 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "3.0.0", + "version": "4.0.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index f139da24b..06c676b25 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -44,10 +44,10 @@ public partial class Container : Item internal int _version; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLiftOverride))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _liftOverride; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLiftOverride() => _liftOverride; public Container(int itemID) : base(itemID) @@ -84,6 +84,7 @@ public partial class Container : Item [EncodedInt] [SerializableProperty(0)] + [SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { @@ -96,14 +97,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; - [SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; [EncodedInt] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int GumpID { @@ -115,14 +115,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeGumpId() => _gumpID != -1; - [SerializableFieldDefault(1)] private int GumpIDDefaultValue() => -1; [EncodedInt] [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DropSound { @@ -134,10 +133,8 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDropSound() => _dropSound != -1; - [SerializableFieldDefault(2)] private int DropSoundDefaultValue() => -1; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/Server/Mobiles/Mods/ResistanceMod.cs b/Projects/Server/Mobiles/Mods/ResistanceMod.cs index bd569f20e..a5423e039 100644 --- a/Projects/Server/Mobiles/Mods/ResistanceMod.cs +++ b/Projects/Server/Mobiles/Mods/ResistanceMod.cs @@ -21,17 +21,15 @@ namespace Server; [SerializationGenerator(0)] public partial class ResistanceMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] private ResistanceType _type; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] private int _offset; - [SerializableFieldChanged(1)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances(); diff --git a/Projects/Server/Mobiles/Mods/SkillMod.cs b/Projects/Server/Mobiles/Mods/SkillMod.cs index a78ec5ce6..cfe96c0c4 100644 --- a/Projects/Server/Mobiles/Mods/SkillMod.cs +++ b/Projects/Server/Mobiles/Mods/SkillMod.cs @@ -21,33 +21,29 @@ namespace Server; [SerializationGenerator(0)] public abstract partial class SkillMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] private bool _obeyCap; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); + private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnSkillChanged))] private SkillName _skill; - [SerializableFieldChanged(1)] private void OnSkillChanged(SkillName oldValue, SkillName newValue) { Owner?.Skills[newValue]?.Update(); Owner?.Skills[oldValue]?.Update(); } - [SerializableField(2)] + [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] private bool _relative; - [SerializableFieldChanged(2)] private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(3)] + [SerializableField(3, fieldChanged: nameof(OnValueChanged))] private double _value; - [SerializableFieldChanged(3)] private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update(); public SkillMod(Mobile owner) : base(owner) diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 522701b78..fd69557b7 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -39,8 +39,8 @@ - - + + diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index 962c3d8e3..f63e4f8d4 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders; public partial class BOBFilter { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeType))] private int _type; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeType() => _type != 0; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeQuality))] private int _quality; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeQuality() => _quality != 0; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMaterial))] private int _material; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeMaterial() => _material != 0; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeQuantity))] private int _quantity; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuantity() => _quantity != 0; private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index bfd00c0bf..644e2b965 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -51,9 +51,9 @@ public partial class ChampionTitleContext } [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeAbyss))] private ChampionTitle _abyss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAbyss() => _abyss != null; [CommandProperty(AccessLevel.GameMaster)] @@ -71,9 +71,9 @@ public partial class ChampionTitleContext } [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeArachnid))] private ChampionTitle _arachnid; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeArachnid() => _arachnid != null; [CommandProperty(AccessLevel.GameMaster)] @@ -91,9 +91,9 @@ public partial class ChampionTitleContext } [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeColdBlood))] private ChampionTitle _coldBlood; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeColdBlood() => _coldBlood != null; [CommandProperty(AccessLevel.GameMaster)] @@ -111,9 +111,9 @@ public partial class ChampionTitleContext } [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeForestLord))] private ChampionTitle _forestLord; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeForestLord() => _forestLord != null; [CommandProperty(AccessLevel.GameMaster)] @@ -131,9 +131,9 @@ public partial class ChampionTitleContext } [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeVerminHorde))] private ChampionTitle _verminHorde; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeVerminHorde() => _verminHorde != null; [CommandProperty(AccessLevel.GameMaster)] @@ -151,9 +151,9 @@ public partial class ChampionTitleContext } [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeUnholyTerror))] private ChampionTitle _unholyTerror; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeUnholyTerror() => _unholyTerror != null; [CommandProperty(AccessLevel.GameMaster)] @@ -171,9 +171,9 @@ public partial class ChampionTitleContext } [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSleepingDragon))] private ChampionTitle _sleepingDragon; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null; [CommandProperty(AccessLevel.GameMaster)] @@ -191,9 +191,9 @@ public partial class ChampionTitleContext } [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeCorrupt))] private ChampionTitle _corrupt; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeCorrupt() => _corrupt != null; [CommandProperty(AccessLevel.GameMaster)] @@ -211,9 +211,9 @@ public partial class ChampionTitleContext } [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeGlade))] private ChampionTitle _glade; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeGlade() => _glade != null; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index f32168cbf..6ab4fffea 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -830,10 +830,9 @@ public partial class BRBomb : Item [SerializationGenerator(0, false)] public partial class BRGoal : BaseAddon { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnNorthChanged))] private bool _north; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnNorthChanged(bool oldValue, bool newValue) => Remake(); diff --git a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs index 6f73a0949..6aec6f328 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item public partial class KHBoard : Item { [SerializedCommandProperty(AccessLevel.GameMaster)] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnControllerChanged))] private KHController _controller; - [SerializableFieldChanged(0)] private void OnControllerChanged(KHController oldValue, KHController newValue) { oldValue?.RemoveBoard(this); diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 60451f53b..09c45fbbd 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable [SerializedIgnoreDupe] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeSecureLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SecureLevel _level; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeSecureLevel() => (int)_level != 0; [SerializedIgnoreDupe] [SerializableField(5, setter: "private")] + [SaveFlag(nameof(ShouldSerializePlantSystem))] private PlantSystem _plantSystem; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant; // For clients older than 7.0.12.0 @@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializePlantStatus))] public PlantStatus PlantStatus { get => _plantStatus; @@ -120,10 +121,10 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializePlantType))] [CommandProperty(AccessLevel.GameMaster)] public PlantType PlantType { @@ -135,10 +136,10 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePlantType() => (int)_plantType != 0; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializePlantHue))] [CommandProperty(AccessLevel.GameMaster)] public PlantHue PlantHue { @@ -150,10 +151,10 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeShowType))] [CommandProperty(AccessLevel.GameMaster)] public bool ShowType { @@ -166,7 +167,6 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeShowType() => _showType; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 29b9f48be..14b071317 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -33,24 +33,24 @@ namespace Server.Engines.Plants private PlantItem _plant; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeFertileDirt))] private bool _fertileDirt; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeFertileDirt() => _fertileDirt; [SerializableField(1)] private DateTime _nextGrowth; [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeGrowthIndicator))] private PlantGrowthIndicator _growthIndicator; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializePollinated))] private bool _pollinated; - [SerializableFieldSaveFlag(13)] private bool ShouldSerializePollinated() => _pollinated; public PlantSystem(PlantItem plant) @@ -98,6 +98,7 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializeWater))] public int Water { get => _water; @@ -109,10 +110,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWater() => _water != 0; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeHits))] public int Hits { get => _hits; @@ -135,7 +136,6 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHits() => _hits != 0; public int MaxHits => 10 + (int)Plant.PlantStatus * 2; @@ -150,6 +150,7 @@ namespace Server.Engines.Plants }; [SerializableProperty(5)] + [SaveFlag(nameof(ShouldSerializeInfestation))] public int Infestation { get => _infestation; @@ -160,10 +161,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeInfestation() => _infestation != 0; [SerializableProperty(6)] + [SaveFlag(nameof(ShouldSerializeFungus))] public int Fungus { get => _fungus; @@ -174,10 +175,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeFungus() => _fungus != 0; [SerializableProperty(7)] + [SaveFlag(nameof(ShouldSerializePoison))] public int Poison { get => _poison; @@ -188,10 +189,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(7)] private bool ShouldSerializePoison() => _poison != 0; [SerializableProperty(8)] + [SaveFlag(nameof(ShouldSerializeDisease))] public int Disease { get => _disease; @@ -202,12 +203,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; [SerializableProperty(9)] + [SaveFlag(nameof(ShouldSerializePoisonPotion))] public int PoisonPotion { get => _poisonPotion; @@ -218,12 +219,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; [SerializableProperty(10)] + [SaveFlag(nameof(ShouldSerializeCurePotion))] public int CurePotion { get => _curePotion; @@ -234,12 +235,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeHealPotion))] public int HealPotion { get => _healPotion; @@ -250,12 +251,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeStrengthPotion))] public int StrengthPotion { get => _strengthPotion; @@ -266,7 +267,6 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2; @@ -274,6 +274,7 @@ namespace Server.Engines.Plants public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeSeedType))] public PlantType SeedType { get => Pollinated ? _seedType : Plant.PlantType; @@ -284,10 +285,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeSeedType() => _pollinated; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeSeedHue))] public PlantHue SeedHue { get => Pollinated ? _seedHue : Plant.PlantHue; @@ -298,53 +299,50 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeSeedHue() => _pollinated; [SerializableProperty(16)] + [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] public int AvailableSeeds { get => _availableSeeds; set => _availableSeeds = Math.Max(value, 0); } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; [SerializableProperty(17)] + [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] public int LeftSeeds { get => _leftSeeds; set => _leftSeeds = Math.Max(value, 0); } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; - [SerializableFieldDefault(17)] private int LeftSeedsDefaultValue() => 8; [SerializableProperty(18)] + [SaveFlag(nameof(ShouldSerializeAvailableResources))] public int AvailableResources { get => _availableResources; set => _availableResources = Math.Max(value, 0); } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeAvailableResources() => _availableResources != 0; [SerializableProperty(19)] + [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] public int LeftResources { get => _leftResources; set => _leftResources = Math.Max(value, 0); } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeLeftResources() => _leftResources != 8; - [SerializableFieldDefault(19)] private int LeftResourcesDefaultValue() => 8; public void Reset(bool potions) diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 7ceeb00f6..a556d6e3f 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private Guid _guid; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; @@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner private int _walkingRange = -1; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeWayPoint() => _wayPoint != null; [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeGroup() => _group; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay; - [SerializableFieldDefault(6)] private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay; - [SerializableFieldDefault(7)] private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner /// If true, the home location of the spawn is the location where it spawned /// If false, the home location of the spawn is the location of the spawner /// - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeEnd() => _end != default; [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; /// /// Controls how spawn position optimization is handled. /// - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner /// /// Maximum number of random position attempts before engaging optimization. /// - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts; - [SerializableFieldDefault(14)] private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 7c07d2681..db25cab55 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner /// When true, enables proactive spiral scanning to find valid spawn positions. /// Only relevant when SpawnPositionMode is Automatic or Enabled. /// - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeUseSpiralScan() => _useSpiralScan; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeUseSpiralScan))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _useSpiralScan; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeSpawnBounds))] [CommandProperty(AccessLevel.Developer)] public override Rectangle3D SpawnBounds { diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 3b26b5294..490002f43 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -10,97 +10,97 @@ public partial class VirtueContext { [DeltaDateTime] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeGain; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); [DeltaDateTime] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeLoss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this); [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAvailableResurrects))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _availableResurrects; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; [DeltaDateTime] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastJusticeLoss; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); [DeltaDateTime] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastCompassionLoss; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); [DeltaDateTime] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextCompassionDay; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now; [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCompassionGains))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _compassionGains; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCompassionGains() => _compassionGains > 0; [DeltaDateTime] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastValorLoss; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); [DeltaDateTime] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _lastHonorUse; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this); [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeHonorActive))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private bool _honorActive; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHonorActive() => _honorActive; [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeJusticeProtection))] private PlayerMobile _justiceProtection; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeJusticeStatus))] private JusticeProtectorStatus _justiceStatus; - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(12, setter: "private")] + [SaveFlag(nameof(ShouldSerializeValues))] private int[] _values; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeValues() { if (_values == null) diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 749c677c2..054fea588 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -31,9 +31,9 @@ namespace Server.Items private bool m_EvaluateDay; [SerializableField(0, setter: "private")] + [DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; - [DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 6f91f3a71..8f87626d7 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -18,95 +18,92 @@ namespace Server.Items { [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeArmorAttributes), nameof(ArmorAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _armorAttributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeArmorAttributes() => !_armorAttributes.IsEmpty; - [SerializableFieldDefault(1)] private AosArmorAttributes ArmorAttributesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializePhysicalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalBonus; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePhysicalBonus() => _physicalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeFireBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireBonus; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeFireBonus() => _fireBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeColdBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldBonus; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeColdBonus() => _coldBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializePoisonBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonBonus; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePoisonBonus() => _poisonBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeEnergyBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyBonus; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeEnergyBonus() => _energyBonus != 0; [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeIdentified() => _identified; [EncodedInt] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeResource() => _resource != DefaultResource; // Field 15 @@ -135,13 +132,12 @@ namespace Server.Items [SerializedIgnoreDupe] [SerializableField(23, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(23)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); private FactionItem m_FactionState; @@ -190,6 +186,7 @@ namespace Server.Items public virtual int OldIntReq => 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public ArmorQuality Quality { @@ -202,13 +199,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular; - [SerializableFieldDefault(11)] private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDurability))] [CommandProperty(AccessLevel.GameMaster)] public ArmorDurabilityLevel Durability { @@ -221,10 +217,10 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; [SerializableProperty(13)] + [SaveFlag(nameof(ShouldSerializeProtectionLevel))] [CommandProperty(AccessLevel.GameMaster)] public ArmorProtectionLevel ProtectionLevel { @@ -244,10 +240,10 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -273,11 +269,11 @@ namespace Server.Items } } - [SerializableFieldDefault(14)] private CraftResource ResourceDefaultValue() => DefaultResource; [EncodedInt] [SerializableProperty(15, useField: nameof(_armorBase))] + [SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int BaseArmorRating { @@ -290,10 +286,8 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeArmorBase() => _armorBase != -1; - [SerializableFieldDefault(15)] private int ArmorBaseDefaultValue() => -1; public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar; @@ -343,6 +337,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(16, useField: nameof(_strBonus))] + [SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrBonus { @@ -355,14 +350,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeStrBonus() => _strBonus != -1; - [SerializableFieldDefault(16)] private int StrBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(17, useField: nameof(_dexBonus))] + [SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexBonus { @@ -375,14 +369,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeDexBonus() => _dexBonus != -1; - [SerializableFieldDefault(17)] private int DexBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(18, useField: nameof(_intBonus))] + [SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntBonus { @@ -395,14 +388,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeIntBonus() => _intBonus != -1; - [SerializableFieldDefault(18)] private int IntBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(19, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -415,14 +407,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeStrReq() => _strReq != -1; - [SerializableFieldDefault(19)] private int StrReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(20, useField: nameof(_dexReq))] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -435,14 +426,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeDexReq() => _dexReq != -1; - [SerializableFieldDefault(20)] private int DexReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(21, useField: nameof(_intReq))] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -455,13 +445,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeIntReq() => _intReq != -1; - [SerializableFieldDefault(21)] private int IntReqDefaultValue() => -1; [SerializableProperty(22, useField: nameof(_meditate))] + [SaveFlag(nameof(ShouldSerializeMeditationAllowance))] [CommandProperty(AccessLevel.GameMaster)] public AMA MeditationAllowance { @@ -473,7 +462,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All; public virtual double ArmorScalar @@ -689,6 +677,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(9)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -716,7 +705,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; diff --git a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs index 4b0fb791f..e44967f0b 100644 --- a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs +++ b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs @@ -31,13 +31,12 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(0)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); public override void AppendChildNameProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index 47f368754..3d9b9f7b8 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -19,41 +19,38 @@ namespace Server.Items [InternString] [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _title; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeTitle() => _title != DefaultContent?.Title; - [SerializableFieldDefault(1)] private string TitleDefaultValue() => DefaultContent?.Title; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _author; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author; - [SerializableFieldDefault(2)] private string AuthorDefaultValue() => DefaultContent?.Author; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeWritable))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _writable; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWritable() => _writable; [SerializedIgnoreDupe] [SerializableField(4, setter: "protected")] + [SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))] private BookPageInfo[] _pages; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true; - [SerializableFieldDefault(4)] private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty(); [Constructible] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 8246c8c46..7d106e7c3 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -26,76 +26,71 @@ namespace Server.Items public abstract partial class BaseClothing : Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem { - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeResource() => _resource != DefaultResource; [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(1)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeClothingAttributes), nameof(ClothingAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _clothingAttributes; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeClothingAttributes() => !_clothingAttributes.IsEmpty; - [SerializableFieldDefault(2)] private AosArmorAttributes ClothingAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(3, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(3)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(4, setter: "private")] + [SaveFlag(nameof(ShouldSerializeResistances), nameof(ResistancesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _resistances; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeResistances() => !_resistances.IsEmpty; - [SerializableFieldDefault(4)] private AosElementAttributes ResistancesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeQuality))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; // Field 9 @@ -119,6 +114,7 @@ namespace Server.Items } [SerializableProperty(0)] + [SaveFlag(nameof(ShouldSerializeResource))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -133,6 +129,7 @@ namespace Server.Items } [SerializableProperty(9, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -145,7 +142,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -299,6 +295,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(6)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -324,7 +321,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index 4fda45bb5..ea2585ad0 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -37,21 +37,22 @@ namespace Server.Items public override double DefaultWeight => 3.0; } - [SerializationGenerator(3, false)] + [SerializationGenerator(4, false)] public partial class DeathRobe : Robe { private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0); - [TimerDrift] [SerializableField(0)] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] - private void DeserializeDecayTimer(TimeSpan delay) + private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + + private void MigrateFrom(V3Content content) { - if (delay != TimeSpan.MinValue) + if (content.DecayTimerDelay != TimeSpan.MinValue) { - BeginDecay(delay); + DeserializeDecayTimer(content.DecayTimerDelay); } } diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index 4d80b4699..b82ae2654 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -54,11 +54,10 @@ namespace Server.Items { [EncodedInt] [InvalidateProperties] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _curArcaneCharges; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update(); diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index a9da67dbb..da4d8b77f 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -3,19 +3,22 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public abstract partial class FillableContainer : LockableContainer { - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeRespawnTimer))] private Timer _respawnTimer; - [DeserializeTimerField(1)] - private void DeserializeRespawnTimer(TimeSpan delay) + private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn); + + private void MigrateFrom(V2Content content) { - if (delay > TimeSpan.MinValue) + _contentType = content.ContentType; + + if (content.RespawnTimerDelay != TimeSpan.MinValue) { - _respawnTimer = Timer.DelayCall(delay, Respawn); + DeserializeRespawnTimer(content.RespawnTimerDelay); } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index fa3f7e440..6a7e073f3 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -3,14 +3,13 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class MarkContainer : LockableContainer { - [TimerDrift] [SerializableField(1, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeRelockTimer))] private InternalTimer _relockTimer; - [DeserializeTimerField(1)] private void DeserializeRelockTimer(TimeSpan delay) { if (!Locked && _autoLock) @@ -19,6 +18,19 @@ public partial class MarkContainer : LockableContainer } } + private void MigrateFrom(V0Content content) + { + _autoLock = content.AutoLock; + _targetMap = content.TargetMap; + _target = content.Target; + _description = content.Description; + + if (content.RelockTimerDelay != TimeSpan.MinValue) + { + DeserializeRelockTimer(content.RelockTimerDelay); + } + } + [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Map _targetMap; diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index e0486182d..a1aafa0b3 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,7 +9,7 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] @@ -29,12 +29,11 @@ public partial class TreasureMapChest : LockableContainer [SerializedCommandProperty(AccessLevel.GameMaster)] private int _level; - [TimerDrift] [SerializableField(4)] [SerializedCommandProperty(AccessLevel.GameMaster)] + [DeserializeTimer(nameof(DeserializeExpireTimer))] private Timer _expireTimer; - [DeserializeTimerField(4)] private void DeserializeExpireTimer(TimeSpan delay) { if (!_temporary) @@ -43,6 +42,20 @@ public partial class TreasureMapChest : LockableContainer } } + private void MigrateFrom(V3Content content) + { + _guardians = content.Guardians; + _temporary = content.Temporary; + _owner = content.Owner; + _level = content.Level; + _lifted = content.Lifted; + + if (content.ExpireTimerDelay != TimeSpan.MinValue) + { + DeserializeExpireTimer(content.ExpireTimerDelay); + } + } + [Tidy] [CanBeNull] [SerializableField(5, setter: "private")] diff --git a/Projects/UOContent/Items/Lights/BaseLight.cs b/Projects/UOContent/Items/Lights/BaseLight.cs index d3bff4af5..4bd63ba63 100644 --- a/Projects/UOContent/Items/Lights/BaseLight.cs +++ b/Projects/UOContent/Items/Lights/BaseLight.cs @@ -3,7 +3,7 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseLight : Item { public static readonly bool Burnout = false; @@ -16,11 +16,10 @@ public abstract partial class BaseLight : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _protected; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeTimer))] private Timer _burnTimer; - [DeserializeTimerField(4)] private void DeserializeTimer(TimeSpan delay) { if (_burning && _duration != TimeSpan.Zero) @@ -29,6 +28,19 @@ public abstract partial class BaseLight : Item } } + private void MigrateFrom(V1Content content) + { + _burntOut = content.BurntOut; + _burning = content.Burning; + _duration = content.Duration; + _protected = content.Protected; + + if (content.BurnTimerDelay != TimeSpan.MinValue) + { + DeserializeTimer(content.BurnTimerDelay); + } + } + [Constructible] public BaseLight(int itemID) : base(itemID) { diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 1ee04170c..59db3dc98 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(17, false)] +[SerializationGenerator(18, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -114,13 +114,37 @@ public partial class Corpse : Container, ICarvable [SerializableField(3, getter: "private", setter: "private")] private Dictionary _restoreTable; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(4)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + private void MigrateFrom(V17Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + [SerializableField(5, setter: "private")] private HashSet _looters; diff --git a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs index ead812e2f..17267a6cc 100644 --- a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs @@ -3,18 +3,25 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class DecayedCorpse : Container { private static TimeSpan _defaultDecayTime = TimeSpan.FromMinutes(7.0); - [TimerDrift] [SerializableField(0, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + private void MigrateFrom(V2Content content) + { + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + public DecayedCorpse(string name) : base(Utility.Random(0xECA, 9)) { Movable = false; diff --git a/Projects/UOContent/Items/Quivers/BaseQuiver.cs b/Projects/UOContent/Items/Quivers/BaseQuiver.cs index 427871f16..70824c9f6 100644 --- a/Projects/UOContent/Items/Quivers/BaseQuiver.cs +++ b/Projects/UOContent/Items/Quivers/BaseQuiver.cs @@ -11,64 +11,62 @@ public partial class BaseQuiver : Container, ICraftable, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLowerAmmoCost))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _lowerAmmoCost; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLowerAmmoCost() => _lowerAmmoCost != 0; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeWeightReduction))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _weightReduction; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeWeightReduction() => _weightReduction != 0; [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDamageIncrease))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageIncrease; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeDamageIncrease() => _damageIncrease != 0; [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - [SerializableFieldDefault(5)] private ClothingQuality QualityDefaultValue() => ClothingQuality.Regular; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCapacity))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _capacity; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCapacity() => _capacity != 0; public BaseQuiver(int itemID = 0x2FB7) : base(itemID) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs index 0a5a0f308..f9ecdfa22 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs @@ -371,27 +371,27 @@ public partial class RunebookEntry private Runebook _runebook; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeHouse))] private BaseHouse _house; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeHouse() => _house?.Deleted == false; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLocation))] private Point3D _location; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeLocation() => _house?.Deleted != false; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMap))] private Map _map; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeMap() => _house?.Deleted != false; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDesc))] private string _description; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeDesc() => _house?.Deleted != false; public RunebookEntry( diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index 3bdfd00fa..b66056831 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -31,6 +31,7 @@ public partial class FountainOfLife : BaseAddonContainer public const int MaxCharges = 10; [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeTimer), wallClock: true)] private Timer _timer; [Constructible] @@ -39,7 +40,6 @@ public partial class FountainOfLife : BaseAddonContainer _charges = charges; } - [DeserializeTimerField(1)] private void DeserializeTimer(TimeSpan delay) { _timer = Timer.DelayCall(Utility.Max(delay, TimeSpan.Zero), RechargeTime, Recharge); diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index 632587523..3853d4594 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -128,136 +128,131 @@ public partial class BaseTalisman : Item, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(1)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeProtection), nameof(ProtectionDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _protection; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeProtection() => !_protection.IsEmpty; - [SerializableFieldDefault(2)] private TalismanAttribute ProtectionDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeKiller), nameof(KillerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _killer; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeKiller() => !_killer.IsEmpty; - [SerializableFieldDefault(3)] private TalismanAttribute KillerDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeSummoner), nameof(SummonerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _summoner; - [SerializableFieldSaveFlag(4)] public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; - [SerializableFieldDefault(4)] private TalismanAttribute SummonerDefaultValue() => new(); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeRemoval))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanRemoval _removal; - [SerializableFieldSaveFlag(5)] public bool ShouldSerializeRemoval() => _removal != TalismanRemoval.None; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSkill))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SkillName _skill; - [SerializableFieldSaveFlag(6)] public bool ShouldSerializeSkill() => (int)_skill != 0; [EncodedInt] [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSuccessBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _successBonus; - [SerializableFieldSaveFlag(7)] public bool ShouldSerializeSuccessBonus() => _successBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeExceptionalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _exceptionalBonus; - [SerializableFieldSaveFlag(8)] public bool ShouldSerializeExceptionalBonus() => _exceptionalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeMaxCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxCharges; - [SerializableFieldSaveFlag(9)] public bool ShouldSerializeMaxCharges() => _maxCharges != 0; [EncodedInt] [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeMaxChargeTime))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxChargeTime; - [SerializableFieldSaveFlag(11)] public bool ShouldSerializeMaxChargeTime() => _maxChargeTime != 0; [EncodedInt] [InvalidateProperties] [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeChargeTime))] private int _chargeTime; - [SerializableFieldSaveFlag(12)] public bool ShouldSerializeChargeTime() => _chargeTime != 0; [InvalidateProperties] [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeBlessed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _blessed; - [SerializableFieldSaveFlag(13)] public bool ShouldSerializeBlessed() => _blessed; [InvalidateProperties] [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanSlayerName _slayer; - [SerializableFieldSaveFlag(14)] public bool ShouldSerializeSlayer() => _slayer != TalismanSlayerName.None; private BaseCreature _creature; @@ -285,6 +280,7 @@ public partial class BaseTalisman : Item, IAosItem public virtual bool ForceShowName => false; // used to override default summoner/removal name [SerializableProperty(10)] + [SaveFlag(nameof(ShouldSerializeCharges))] [CommandProperty(AccessLevel.GameMaster)] public int Charges { @@ -303,7 +299,6 @@ public partial class BaseTalisman : Item, IAosItem } } - [SerializableFieldSaveFlag(10)] public bool ShouldSerializeCharges() => _charges != 0; public static void Configure() diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 1f03daf63..d48824670 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -56,130 +56,126 @@ public abstract partial class BaseWeapon [InvalidateProperties] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeDamageLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private WeaponDamageLevel _damageLevel; - [SerializableFieldSaveFlag(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeDamageLevel() => _damageLevel != WeaponDamageLevel.Regular; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer; - [SerializableFieldSaveFlag(6)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer() => _slayer != SlayerName.None; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializePoison))] [SerializedCommandProperty(AccessLevel.GameMaster)] private Poison _poison; - [SerializableFieldSaveFlag(7)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoison() => _poison != null; [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePoisonCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonCharges; - [SerializableFieldSaveFlag(8)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoisonCharges() => _poisonCharges > 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(9)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(10)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeIdentified() => _identified; [SerializedIgnoreDupe] [SerializableField(24, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(24)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(24)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(25, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(25)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(25)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(26, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(26)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(26)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeSlayer2))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; - [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] [SerializableField(28, setter: "private")] + [SaveFlag(nameof(ShouldSerializeElementAttributes), nameof(AosElementAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; - [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; - [SerializableFieldDefault(28)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeEngravedText))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; - [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -286,6 +282,7 @@ public abstract partial class BaseWeapon public bool Consecrated { get; set; } [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeWeaponAccuracy))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAccuracyLevel AccuracyLevel { @@ -321,10 +318,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeWeaponAccuracy() => _accuracyLevel != WeaponAccuracyLevel.Regular; [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDurabilityLevel))] [CommandProperty(AccessLevel.GameMaster)] public WeaponDurabilityLevel DurabilityLevel { @@ -339,10 +336,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDurabilityLevel() => _durabilityLevel != WeaponDurabilityLevel.Regular; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponQuality Quality { @@ -357,13 +354,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuality() => _quality != WeaponQuality.Regular; - [SerializableFieldDefault(3)] private WeaponQuality QualityDefaultValue() => WeaponQuality.Regular; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -387,10 +383,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHitPoints() => _hitPoints > 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -403,13 +399,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeStrReq() => _strRequirement != -1; - [SerializableFieldDefault(11)] private int StrReqDefaultValue() => -1; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -422,13 +417,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDexReq() => _dexRequirement != -1; - [SerializableFieldDefault(12)] private int DexReqDefaultValue() => -1; [SerializableProperty(13)] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -441,13 +435,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeIntReq() => _intRequirement != -1; - [SerializableFieldDefault(13)] private int IntReqDefaultValue() => -1; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeMinDamage), nameof(MinDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MinDamage { @@ -460,13 +453,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMinDamage() => _minDamage != -1; - [SerializableFieldDefault(14)] private int MinDamageDefaultValue() => -1; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeMaxDamage), nameof(MaxDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxDamage { @@ -479,13 +471,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeMaxDamage() => _maxDamage != -1; - [SerializableFieldDefault(15)] private int MaxDamageDefaultValue() => -1; [SerializableProperty(16)] + [SaveFlag(nameof(ShouldSerializeHitSound), nameof(HitSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int HitSound { @@ -497,13 +488,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeHitSound() => _hitSound != -1; - [SerializableFieldDefault(16)] private int HitSoundDefaultValue() => -1; [SerializableProperty(17)] + [SaveFlag(nameof(ShouldSerializeMissSound), nameof(MissSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MissSound { @@ -515,13 +505,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeMissSound() => _missSound != -1; - [SerializableFieldDefault(17)] private int MissSoundDefaultValue() => -1; [SerializableProperty(18)] + [SaveFlag(nameof(ShouldSerializeSpeed), nameof(SpeedDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public float Speed { @@ -552,13 +541,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeSpeed() => _speed != -1; - [SerializableFieldDefault(18)] private float SpeedDefaultValue() => -1; [SerializableProperty(19)] + [SaveFlag(nameof(ShouldSerializeMaxRange), nameof(MaxRangeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxRange { @@ -571,13 +559,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeMaxRange() => _maxRange != -1; - [SerializableFieldDefault(19)] private int MaxRangeDefaultValue() => -1; [SerializableProperty(20)] + [SaveFlag(nameof(ShouldSerializeSkill), nameof(SkillNameDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public SkillName Skill { @@ -590,13 +577,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeSkill() => _skill != (SkillName)(-1); - [SerializableFieldDefault(20)] private SkillName SkillNameDefaultValue() => (SkillName)(-1); [SerializableProperty(21)] + [SaveFlag(nameof(ShouldSerializeType), nameof(TypeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponType Type { @@ -608,13 +594,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeType() => _type != (WeaponType)(-1); - [SerializableFieldDefault(21)] private WeaponType TypeDefaultValue() => (WeaponType)(-1); [SerializableProperty(22)] + [SaveFlag(nameof(ShouldSerializeAnimation), nameof(AnimationDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAnimation Animation { @@ -626,13 +611,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeAnimation() => _animation != (WeaponAnimation)(-1); - [SerializableFieldDefault(22)] private WeaponAnimation AnimationDefaultValue() => (WeaponAnimation)(-1); [SerializableProperty(23)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -648,10 +632,8 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeResource() => _resource != CraftResource.Iron; - [SerializableFieldDefault(23)] private CraftResource ResourceDefaultValue() => CraftResource.Iron; public virtual int OnCraft( diff --git a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json new file mode 100644 index 000000000..c04a03f3b --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json @@ -0,0 +1,43 @@ +{ + "version": 2, + "type": "Server.Items.BaseLight", + "properties": [ + { + "name": "BurntOut", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Burning", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Duration", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Protected", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BurnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json new file mode 100644 index 000000000..452e3ba7c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json @@ -0,0 +1,137 @@ +{ + "version": 18, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "DeltaTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json new file mode 100644 index 000000000..ef81d79cd --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json @@ -0,0 +1,14 @@ +{ + "version": 4, + "type": "Server.Items.DeathRobe", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json new file mode 100644 index 000000000..adcf02ff6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json @@ -0,0 +1,14 @@ +{ + "version": 3, + "type": "Server.Items.DecayedCorpse", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json new file mode 100644 index 000000000..2384676a3 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json @@ -0,0 +1,19 @@ +{ + "version": 3, + "type": "Server.Items.FillableContainer", + "properties": [ + { + "name": "ContentType", + "type": "Server.Items.FillableContentType", + "rule": "EnumMigrationRule" + }, + { + "name": "RespawnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json new file mode 100644 index 000000000..267ca4709 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "type": "Server.Items.MarkContainer", + "properties": [ + { + "name": "AutoLock", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RelockTimer", + "type": "Server.Items.MarkContainer.InternalTimer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "TargetMap", + "type": "Server.Map", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Target", + "type": "Server.Point3D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "Description", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json new file mode 100644 index 000000000..b4d1f85d4 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json @@ -0,0 +1,57 @@ +{ + "version": 4, + "type": "Server.Items.TreasureMapChest", + "properties": [ + { + "name": "Guardians", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Temporary", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ExpireTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Lifted", + "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json new file mode 100644 index 000000000..5149f4347 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json @@ -0,0 +1,43 @@ +{ + "version": 3, + "type": "Server.Mobiles.BaseEscortable", + "properties": [ + { + "name": "DestinationString", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DeleteTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "MlQuestType", + "type": "System.Type", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MlQuestDestinationMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + }, + { + "name": "MlQuestPaymentMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index f13cebcbe..e2018ab14 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -11,19 +11,19 @@ namespace Server.Mobiles public partial class EtherealMount : Item, IMount, IMountItem, IRewardItem { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeIsDonationItem))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool _isDonationItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeIsDonationItem() => _isDonationItem; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeIsRewardItem))] [SerializedCommandProperty(AccessLevel.GameMaster)] public bool _isRewardItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeIsRewardItem() => _isRewardItem; [Constructible] @@ -87,6 +87,7 @@ namespace Server.Mobiles public virtual int EtherealHue => 0x4001; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeRider))] [CommandProperty(AccessLevel.GameMaster)] public Mobile Rider { @@ -124,11 +125,11 @@ namespace Server.Mobiles } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeRider() => _rider != null; [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(5)] + [SaveFlag(nameof(ShouldSerializeSteps))] public int Steps { get => _steps; @@ -139,7 +140,6 @@ namespace Server.Mobiles } } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeSteps() => _steps != StepsMax; public virtual int StepsMax => 3840; // Should be same as horse diff --git a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs index 462307be3..cf439a76c 100644 --- a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs +++ b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs @@ -17,7 +17,7 @@ using EDI = Server.Mobiles.EscortDestinationInfo; namespace Server.Mobiles; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class BaseEscortable : BaseCreature { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseEscortable)); @@ -158,16 +158,22 @@ public partial class BaseEscortable : BaseCreature [SerializableField(0, setter: "private")] private string _destinationString; - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeDeleteTimer))] private Timer _deleteTimer; - [DeserializeTimerField(1)] - private void DeserializeDeleteTimer(TimeSpan delay) + private void DeserializeDeleteTimer(TimeSpan delay) => Timer.DelayCall(delay, Delete); + + private void MigrateFrom(V2Content content) { - if (delay >= TimeSpan.Zero) + _destinationString = content.DestinationString; + _mlQuestType = content.MlQuestType; + _mlQuestDestinationMessage = content.MlQuestDestinationMessage; + _mlQuestPaymentMessage = content.MlQuestPaymentMessage; + + if (content.DeleteTimerDelay != TimeSpan.MinValue) { - Timer.DelayCall(delay, Delete); + DeserializeDeleteTimer(content.DeleteTimerDelay); } } diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 5c7b74214..60f28ac6f 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -50,8 +50,8 @@ - - + + From b042edcf0be1d268238490253a76902010fe7c5e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:20:39 -0700 Subject: [PATCH 07/20] refactor: fold hand-written serializable property setters into field hooks (#2587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate. ```cs // before [SerializableProperty(1)] [CommandProperty(AccessLevel.GameMaster)] public int Charges { get => _charges; set { _charges = Math.Clamp(value, 0, MaxCharges); InvalidateProperties(); this.MarkDirty(); } } // after [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] [SerializedCommandProperty(AccessLevel.GameMaster)] [InvalidateProperties] private int _charges; private bool AllowChargesChange(ref int value) { value = Math.Clamp(value, 0, MaxCharges); return true; } ``` ## How sites were selected A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately: - **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these. - **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight). - **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field). Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`). ## Verification - Build: **0 errors, 0 warnings**. - **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it. - **835 + 708 tests green.** ## Behavioral notes (all strict improvements, called out for review) - Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes). - Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction. --- .../CannedEvil/ChampionSkullBrazier.cs | 16 +- .../Engines/CannedEvil/ChampionSpawn.cs | 57 +++-- Projects/UOContent/Engines/ConPVP/Trophy.cs | 16 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 14 +- .../UOContent/Engines/Plants/PlantItem.cs | 46 ++-- .../UOContent/Engines/Plants/PlantSystem.cs | 203 +++++++++--------- Projects/UOContent/Engines/Plants/Seed.cs | 18 +- .../Player Murder System/MurderContext.cs | 12 +- .../The Summoning/Items/SummoningAltar.cs | 14 +- .../UOContent/Engines/Spawners/BaseSpawner.cs | 30 ++- .../Treasures of Tokuno/GreaterArtifacts.cs | 33 ++- .../Treasures of Tokuno/LesserArtifacts.cs | 33 ++- .../Character Statue Maker/CharacterStatue.cs | 52 ++--- .../CharacterStatueMaker.cs | 16 +- Projects/UOContent/Items/Addons/BaseAddon.cs | 22 +- .../Items/Addons/BaseAddonContainer.cs | 22 +- .../Items/Addons/BaseAddonContainerDeed.cs | 22 +- .../Items/Addons/FlourMillEastAddon.cs | 22 +- .../Items/Addons/FlourMillSouthAddon.cs | 21 +- .../UOContent/Items/Addons/SHTeleporter.cs | 40 ++-- .../UOContent/Items/Aquarium/AquariumState.cs | 19 +- Projects/UOContent/Items/Armor/BaseArmor.cs | 25 +-- .../Items/Armor/Leather/LeafGloves.cs | 34 ++- .../Items/Armor/Leather/LeatherGloves.cs | 34 ++- .../UOContent/Items/Clothing/BaseClothing.cs | 18 +- Projects/UOContent/Items/Clothing/Cloaks.cs | 36 ++-- .../UOContent/Items/Clothing/OuterTorso.cs | 36 ++-- Projects/UOContent/Items/Clothing/Shoes.cs | 18 +- .../Items/Construction/Doors/BaseDoor.cs | 58 ++--- .../Items/Containers/MarkContainer.cs | 28 ++- .../Items/Deeds/DragonBardingDeed.cs | 17 +- Projects/UOContent/Items/Food/Beverage.cs | 32 ++- Projects/UOContent/Items/Food/Cooking.cs | 34 +-- .../Items/Games/Mahjong/MahjongGame.cs | 60 ++---- Projects/UOContent/Items/Jewels/BaseJewel.cs | 15 +- .../Items/Misc/CommunicationCrystals.cs | 22 +- Projects/UOContent/Items/Misc/MorphItem.cs | 24 ++- Projects/UOContent/Items/Misc/WarningItem.cs | 20 +- .../Items/Resources/Blacksmithing/Ingots.cs | 22 +- .../Items/Resources/Blacksmithing/Ore.cs | 22 +- .../Items/Resources/Blacksmithing/Scales.cs | 22 +- .../Items/Resources/Masonry/Granite.cs | 22 +- .../UOContent/Items/Resources/Tailor/Hides.cs | 22 +- .../Items/Resources/Tailor/Leathers.cs | 22 +- .../Skill Items/Carpenter Items/Board.cs | 22 +- .../Fishing/Misc/MessageInABottle.cs | 16 +- .../Items/Skill Items/Fishing/Misc/SOS.cs | 24 ++- .../Items/Skill Items/Lumberjack/Log.cs | 22 +- .../Skill Items/Magical/Misc/RecallRune.cs | 40 ++-- .../Items/Skill Items/Magical/Spellbook.cs | 31 +-- .../Items/Skill Items/Misc/RepairDeed.cs | 18 +- .../Musical Instruments/BaseInstrument.cs | 15 +- .../Tailor Items/Dyetubs/DyeTub.cs | 21 +- .../Items/Skill Items/Tools/BaseRunicTool.cs | 17 +- .../8th Anniversary Items/FountainOfLife.cs | 36 ++-- .../Special/Heritage Items/FruitTrees.cs | 12 +- .../Special/House Raffle/HouseRaffleStone.cs | 56 ++--- .../Items/Special/MonsterStatuette.cs | 22 +- .../Items/Special/Solen Items/BagOfSending.cs | 65 +++--- .../Special/Solen Items/BallOfSummoning.cs | 36 ++-- .../Special/Solen Items/BraceletOfBinding.cs | 36 ++-- Projects/UOContent/Items/Special/SoulStone.cs | 20 +- Projects/UOContent/Items/Suits/BaseSuit.cs | 17 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 22 +- Projects/UOContent/Misc/ShardPoller.cs | 64 +++--- .../UOContent/Mobiles/Animals/Misc/Sheep.cs | 16 +- .../Mobiles/Animals/Mounts/Ethereals.cs | 64 ++---- .../Mobiles/Animals/Mounts/SwampDragon.cs | 61 +++--- .../UOContent/Mobiles/Hireables/BaseHire.cs | 19 +- .../Vendors/Barkeeper/PlayerBarkeeper.cs | 17 +- .../UOContent/Mobiles/Vendors/PlayerVendor.cs | 17 +- .../UOContent/Mobiles/Vendors/VendorItem.cs | 24 ++- Projects/UOContent/Multis/Boats/BaseBoat.cs | 32 ++- 73 files changed, 893 insertions(+), 1340 deletions(-) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs index 9ff8dc633..2632fd954 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs @@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent [SerializedCommandProperty(AccessLevel.GameMaster)] private ChampionSkullPlatform _platform; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public Item Skull + [SerializableField(2, fieldChanged: nameof(OnSkullChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Item _skull; + + private void OnSkullChanged(Item oldValue, Item newValue) { - get => _skull; - set - { - _skull = value; - this.MarkDirty(); - _platform?.Validate(); - } + _platform?.Validate(); } public override int LabelNumber => 1049489 + (int)_type; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index a26bcc0f9..d1e14c834 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -203,47 +203,38 @@ public partial class ChampionSpawn : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public int MaxLevel + [SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private int _maxLevel; + + private bool AllowMaxLevelChange(ref int value) { - get => _maxLevel; - set => _maxLevel = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(9)] - [CommandProperty(AccessLevel.GameMaster)] - public Rectangle2D SpawnArea + [SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private Rectangle2D _spawnArea; + + private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue) { - get => _spawnArea; - set - { - _spawnArea = value; - this.MarkDirty(); - InvalidateProperties(); - UpdateRegion(); - } + UpdateRegion(); } - [SerializableProperty(11)] - [CommandProperty(AccessLevel.GameMaster)] - public int Kills + [SerializableField(11, fieldChanged: nameof(OnKillsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _kills; + + private void OnKillsChanged(int oldValue, int newValue) { - get => _kills; - set + var n = _kills / (double)MaxKills; + var p = (int)(n * 100); + if (p < 90) { - _kills = value; - this.MarkDirty(); - - var n = _kills / (double)MaxKills; - var p = (int)(n * 100); - - if (p < 90) - { - SetWhiteSkullCount(p / 20); - } - - InvalidateProperties(); + SetWhiteSkullCount(p / 20); } } diff --git a/Projects/UOContent/Engines/ConPVP/Trophy.cs b/Projects/UOContent/Engines/ConPVP/Trophy.cs index fa712de33..603b52ef3 100644 --- a/Projects/UOContent/Engines/ConPVP/Trophy.cs +++ b/Projects/UOContent/Engines/ConPVP/Trophy.cs @@ -64,17 +64,13 @@ public partial class Trophy : Item UpdateStyle(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public TrophyRank Rank + [SerializableField(1, fieldChanged: nameof(OnRankChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private TrophyRank _rank; + + private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue) { - get => _rank; - set - { - _rank = value; - UpdateStyle(); - this.MarkDirty(); - } + UpdateStyle(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 4548522b4..fbb3af7b6 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -237,16 +237,12 @@ namespace Server.Items } } - [SerializableProperty(0)] - public PuzzleChestSolution Solution + [SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] + private PuzzleChestSolution _solution; + + private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue) { - get => _solution; - set - { - _solution = value; - InitHints(); - this.MarkDirty(); - } + InitHints(); } public PuzzleChestCylinder FirstHint diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 09c45fbbd..5f66b1a7a 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -123,49 +123,35 @@ public partial class PlantItem : Item, ISecurable private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; - [SerializableProperty(2)] + [SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))] [SaveFlag(nameof(ShouldSerializePlantType))] - [CommandProperty(AccessLevel.GameMaster)] - public PlantType PlantType + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantType _plantType; + + private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue) { - get => _plantType; - set - { - _plantType = value; - Update(); - } + Update(); } private bool ShouldSerializePlantType() => (int)_plantType != 0; - [SerializableProperty(3)] + [SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))] [SaveFlag(nameof(ShouldSerializePlantHue))] - [CommandProperty(AccessLevel.GameMaster)] - public PlantHue PlantHue + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Update(); - } + Update(); } private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; - [SerializableProperty(4)] + [SerializableField(4)] [SaveFlag(nameof(ShouldSerializeShowType))] - [CommandProperty(AccessLevel.GameMaster)] - public bool ShowType - { - get => _showType; - set - { - _showType = value; - InvalidateProperties(); - this.MarkDirty(); - } - } + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _showType; private bool ShouldSerializeShowType() => _showType; diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 14b071317..4b202ef63 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -97,43 +97,40 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; - [SerializableProperty(3)] + [SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))] [SaveFlag(nameof(ShouldSerializeWater))] - public int Water + private int _water; + + private bool AllowWaterChange(ref int value) { - get => _water; - set - { - _water = Math.Clamp(value, 0, 4); - Plant.InvalidateProperties(); - MarkDirty(); - } + value = Math.Clamp(value, 0, 4); + return true; + } + + private void OnWaterChanged(int oldValue, int newValue) + { + Plant.InvalidateProperties(); } private bool ShouldSerializeWater() => _water != 0; - [SerializableProperty(4)] + [SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))] [SaveFlag(nameof(ShouldSerializeHits))] - public int Hits + private int _hits; + + private bool AllowHitsChange(ref int value) { - get => _hits; - set + value = Math.Clamp(value, 0, MaxHits); + return true; + } + + private void OnHitsChanged(int oldValue, int newValue) + { + if (_hits == 0) { - if (_hits == value) - { - return; - } - - _hits = Math.Clamp(value, 0, MaxHits); - - if (_hits == 0) - { - Plant.Die(); - } - - Plant.InvalidateProperties(); - MarkDirty(); + Plant.Die(); } + Plant.InvalidateProperties(); } private bool ShouldSerializeHits() => _hits != 0; @@ -149,122 +146,106 @@ namespace Server.Engines.Plants _ => PlantHealth.Vibrant }; - [SerializableProperty(5)] + [SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))] [SaveFlag(nameof(ShouldSerializeInfestation))] - public int Infestation + private int _infestation; + + private bool AllowInfestationChange(ref int value) { - get => _infestation; - set - { - _infestation = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeInfestation() => _infestation != 0; - [SerializableProperty(6)] + [SerializableField(6, allowFieldChange: nameof(AllowFungusChange))] [SaveFlag(nameof(ShouldSerializeFungus))] - public int Fungus + private int _fungus; + + private bool AllowFungusChange(ref int value) { - get => _fungus; - set - { - _fungus = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeFungus() => _fungus != 0; - [SerializableProperty(7)] + [SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))] [SaveFlag(nameof(ShouldSerializePoison))] - public int Poison + private int _poison; + + private bool AllowPoisonChange(ref int value) { - get => _poison; - set - { - _poison = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializePoison() => _poison != 0; - [SerializableProperty(8)] + [SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))] [SaveFlag(nameof(ShouldSerializeDisease))] - public int Disease + private int _disease; + + private bool AllowDiseaseChange(ref int value) { - get => _disease; - set - { - _disease = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; - [SerializableProperty(9)] + [SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))] [SaveFlag(nameof(ShouldSerializePoisonPotion))] - public int PoisonPotion + private int _poisonPotion; + + private bool AllowPoisonPotionChange(ref int value) { - get => _poisonPotion; - set - { - _poisonPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; - [SerializableProperty(10)] + [SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))] [SaveFlag(nameof(ShouldSerializeCurePotion))] - public int CurePotion + private int _curePotion; + + private bool AllowCurePotionChange(ref int value) { - get => _curePotion; - set - { - _curePotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; - [SerializableProperty(11)] + [SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))] [SaveFlag(nameof(ShouldSerializeHealPotion))] - public int HealPotion + private int _healPotion; + + private bool AllowHealPotionChange(ref int value) { - get => _healPotion; - set - { - _healPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; - [SerializableProperty(12)] + [SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))] [SaveFlag(nameof(ShouldSerializeStrengthPotion))] - public int StrengthPotion + private int _strengthPotion; + + private bool AllowStrengthPotionChange(ref int value) { - get => _strengthPotion; - set - { - _strengthPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; @@ -301,44 +282,52 @@ namespace Server.Engines.Plants private bool ShouldSerializeSeedHue() => _pollinated; - [SerializableProperty(16)] + [SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))] [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] - public int AvailableSeeds + private int _availableSeeds; + + private bool AllowAvailableSeedsChange(ref int value) { - get => _availableSeeds; - set => _availableSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; - [SerializableProperty(17)] + [SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))] [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] - public int LeftSeeds + private int _leftSeeds; + + private bool AllowLeftSeedsChange(ref int value) { - get => _leftSeeds; - set => _leftSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; private int LeftSeedsDefaultValue() => 8; - [SerializableProperty(18)] + [SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))] [SaveFlag(nameof(ShouldSerializeAvailableResources))] - public int AvailableResources + private int _availableResources; + + private bool AllowAvailableResourcesChange(ref int value) { - get => _availableResources; - set => _availableResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeAvailableResources() => _availableResources != 0; - [SerializableProperty(19)] + [SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))] [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] - public int LeftResources + private int _leftResources; + + private bool AllowLeftResourcesChange(ref int value) { - get => _leftResources; - set => _leftResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeLeftResources() => _leftResources != 8; diff --git a/Projects/UOContent/Engines/Plants/Seed.cs b/Projects/UOContent/Engines/Plants/Seed.cs index 24a290f0b..ed6f13b7b 100644 --- a/Projects/UOContent/Engines/Plants/Seed.cs +++ b/Projects/UOContent/Engines/Plants/Seed.cs @@ -35,18 +35,14 @@ public partial class Seed : Item public override double DefaultWeight => 1.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public PlantHue PlantHue + [SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Hue = PlantHueInfo.GetInfo(value).Hue; - InvalidateProperties(); - this.MarkDirty(); - } + Hue = PlantHueInfo.GetInfo(newValue).Hue; } public override int LabelNumber => 1060810; // seed diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index cecd7bf7e..384c91c98 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -16,12 +16,14 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _longTermElapse; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int ShortTermMurders + [SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _shortTermMurders; + + private bool AllowShortTermMurdersChange(ref int value) { - get => _shortTermMurders; - set => _shortTermMurders = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } [SerializableField(3)] diff --git a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs index d5ec6f24a..4e3e5e237 100644 --- a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs +++ b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs @@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon { } - [SerializableProperty(0)] - public BoneDemon Daemon + [SerializableField(0, fieldChanged: nameof(OnDaemonChanged))] + private BoneDemon _daemon; + + private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue) { - get => _daemon; - set - { - _daemon = value; - CheckDaemon(); - this.MarkDirty(); - } + CheckDaemon(); } public void CheckDaemon() diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index a556d6e3f..8efc54b8c 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -311,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(8)] - [CommandProperty(AccessLevel.Developer)] - public int Count + [SerializableField(8, fieldChanged: nameof(OnCountChanged))] + [SerializedCommandProperty(AccessLevel.Developer)] + [InvalidateProperties] + private int _count; + + private void OnCountChanged(int oldValue, int newValue) { - get => _count; - set + if (IsFull) { - _count = value; - - if (IsFull) - { - _timer?.Stop(); - } - else if (_timer?.Running != true) - { - DoTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + _timer?.Stop(); + } + else if (_timer?.Running != true) + { + DoTimer(); } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs index 005688e1e..c42cfae4e 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs @@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public PigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PigmentType _type; + + private void OnTypeChanged(PigmentType oldValue, PigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs index 54d026df4..22c6af344 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs @@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public LesserPigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private LesserPigmentType _type; + + private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs index ff665ec53..7a62cc735 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs @@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem InvalidateHues(); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatuePose Pose + [SerializableField(1, fieldChanged: nameof(OnPoseChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatuePose _pose; + + private void OnPoseChanged(StatuePose oldValue, StatuePose newValue) { - get => _pose; - set - { - _pose = value; - InvalidatePose(); - this.MarkDirty(); - } + InvalidatePose(); } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueMaterial Material + [SerializableField(2, fieldChanged: nameof(OnMaterialChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueMaterial _material; + + private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue) { - get => _material; - set - { - _material = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs index a028b1c85..48ae30ae9 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs @@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem public override int LabelNumber => 1076173; // Character Statue Maker - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHue(); - this.MarkDirty(); - } + InvalidateHue(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Items/Addons/BaseAddon.cs b/Projects/UOContent/Items/Addons/BaseAddon.cs index 6b2abbc66..f2a662f6a 100644 --- a/Projects/UOContent/Items/Addons/BaseAddon.cs +++ b/Projects/UOContent/Items/Addons/BaseAddon.cs @@ -63,22 +63,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } Item IAddon.Deed => Deed; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs index ad655c24e..8554ad4e5 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs @@ -41,22 +41,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual bool RetainDeedHue => false; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs index c7e08e955..7d5014dee 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs @@ -23,22 +23,14 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable public abstract BaseAddonContainer Addon { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual int OnCraft( diff --git a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs index f84c959cf..decf3ca65 100644 --- a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs @@ -48,17 +48,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Clamp(value, 0, MaxFlour); - UpdateStage(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxFlour); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs index cbd0e2cc3..fa48aa83c 100644 --- a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs @@ -35,16 +35,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Max(0, Math.Min(value, MaxFlour)); - UpdateStage(); - } + value = Math.Max(0, Math.Min(value, MaxFlour)); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/SHTeleporter.cs b/Projects/UOContent/Items/Addons/SHTeleporter.cs index f34f9cb85..e6ce612b4 100644 --- a/Projects/UOContent/Items/Addons/SHTeleporter.cs +++ b/Projects/UOContent/Items/Addons/SHTeleporter.cs @@ -25,35 +25,27 @@ namespace Server.Items _teleOffset = offset; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Active - { - get => _active; - set - { - _active = value; + [SerializableField(0, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _active; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeActive(value); - } + private void OnActiveChanged(bool oldValue, bool newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeActive(newValue); } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public SHTeleComponent TeleDest - { - get => _teleDest; - set - { - _teleDest = value; + [SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private SHTeleComponent _teleDest; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeDest(value); - } + private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeDest(newValue); } } diff --git a/Projects/UOContent/Items/Aquarium/AquariumState.cs b/Projects/UOContent/Items/Aquarium/AquariumState.cs index cdd59d9d9..75fe6fc6f 100644 --- a/Projects/UOContent/Items/Aquarium/AquariumState.cs +++ b/Projects/UOContent/Items/Aquarium/AquariumState.cs @@ -30,19 +30,14 @@ namespace Server.Items public AquariumState(Aquarium parent) => _aquarium = parent; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int State + [SerializableField(0, allowFieldChange: nameof(AllowStateChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _state; + + private bool AllowStateChange(ref int value) { - get => _state; - set - { - if (_state != value) - { - _state = Math.Clamp(value, 0, 4); - MarkDirty(); - } - } + value = Math.Clamp(value, 0, 4); + return true; } [SerializableField(1)] diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 8f87626d7..f4062326d 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -219,25 +219,16 @@ namespace Server.Items private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; - [SerializableProperty(13)] + [SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))] [SaveFlag(nameof(ShouldSerializeProtectionLevel))] - [CommandProperty(AccessLevel.GameMaster)] - public ArmorProtectionLevel ProtectionLevel + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ArmorProtectionLevel _protectionLevel; + + private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue) { - get => _protectionLevel; - set - { - if (_protectionLevel != value) - { - _protectionLevel = value; - - Invalidate(); - InvalidateProperties(); - - (Parent as Mobile)?.UpdateResistances(); - this.MarkDirty(); - } - } + Invalidate(); + (Parent as Mobile)?.UpdateResistances(); } private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; diff --git a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs index f9abd485b..aa023cdc3 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs @@ -33,32 +33,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs index a1ded151b..297bd8ed1 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs @@ -32,32 +32,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 7d106e7c3..983568d2d 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -113,19 +113,15 @@ namespace Server.Items Resistances = new AosElementAttributes(this); } - [SerializableProperty(0)] + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] [SaveFlag(nameof(ShouldSerializeResource))] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - this.MarkDirty(); - } + Hue = CraftResources.GetHue(_resource); } [SerializableProperty(9, useField: nameof(_strReq))] diff --git a/Projects/UOContent/Items/Clothing/Cloaks.cs b/Projects/UOContent/Items/Clothing/Cloaks.cs index 67f572d9d..a92e8fc8c 100644 --- a/Projects/UOContent/Items/Clothing/Cloaks.cs +++ b/Projects/UOContent/Items/Clothing/Cloaks.cs @@ -22,34 +22,26 @@ namespace Server.Items public override double DefaultWeight => 5.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index ea2585ad0..f3b0b077f 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -325,34 +325,26 @@ namespace Server.Items public override double DefaultWeight => 3.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index b82ae2654..f791b3a62 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -70,19 +70,15 @@ namespace Server.Items public override CraftResource DefaultResource => CraftResource.RegularLeather; + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index dc3449c24..ef779760b 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -74,43 +74,31 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable Movable = false; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Open + [SerializableField(1, fieldChanged: nameof(OnOpenChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _open; + + private void OnOpenChanged(bool oldValue, bool newValue) { - get => _open; - set + ItemID = _open ? _openedId : _closedId; + if (_open) { - if (_open != value) - { - _open = value; - - ItemID = _open ? _openedId : _closedId; - - if (_open) - { - Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); - } - else - { - Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); - } - - Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); - - if (_open) - { - _timer ??= new InternalTimer(this); - _timer.Start(); - } - else - { - _timer.Stop(); - _timer = null; - } - - this.MarkDirty(); - } + Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); + } + else + { + Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); + } + Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); + if (_open) + { + _timer ??= new InternalTimer(this); + _timer.Start(); + } + else + { + _timer.Stop(); + _timer = null; } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index 6a7e073f3..04e7c89ea 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -62,23 +62,19 @@ public partial class MarkContainer : LockableContainer } } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool AutoLock - { - get => _autoLock; - set - { - _autoLock = value; + [SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _autoLock; - if (!_autoLock) - { - StopTimer(); - } - else if (!Locked) - { - _relockTimer ??= new InternalTimer(this); - } + private void OnAutoLockChanged(bool oldValue, bool newValue) + { + if (!_autoLock) + { + StopTimer(); + } + else if (!Locked) + { + _relockTimer ??= new InternalTimer(this); } } diff --git a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs index 6599e6c56..5ed370153 100644 --- a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs +++ b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs @@ -28,17 +28,14 @@ public partial class DragonBardingDeed : Item, ICraftable public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(value); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(newValue); } public int OnCraft( diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 3a9f50297..85eecf17f 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -324,27 +324,21 @@ public abstract partial class BaseBeverage : Item, IHasQuantity [CommandProperty(AccessLevel.GameMaster)] public bool IsFull => _quantity >= MaxQuantity; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public BeverageType Content + [SerializableField(2, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private BeverageType _content; + + private void OnContentChanged(BeverageType oldValue, BeverageType newValue) { - get => _content; - set + var itemID = ComputeItemID(); + if (itemID > 0) { - _content = value; - - InvalidateProperties(); - - var itemID = ComputeItemID(); - - if (itemID > 0) - { - ItemID = itemID; - } - else - { - Delete(); - } + ItemID = itemID; + } + else + { + Delete(); } } diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index 6cff16d72..cd010432d 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -76,25 +76,25 @@ public partial class SackFlour : Item, IHasQuantity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Quantity + [SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _quantity; + + private bool AllowQuantityChange(ref int value) { - get => _quantity; - set + value = Math.Min(20, Math.Max(0, value)); + return true; + } + + private void OnQuantityChanged(int oldValue, int newValue) + { + if (_quantity == 0) { - _quantity = Math.Min(20, Math.Max(0, value)); - - if (_quantity == 0) - { - Delete(); - } - else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) - { - ++ItemID; - } - - this.MarkDirty(); + Delete(); + } + else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) + { + ++ItemID; } } diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs index de7e663b7..89e4d6f8f 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs @@ -55,55 +55,33 @@ public partial class MahjongGame : Item, ISecurable public override double DefaultWeight => 5.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(6)] - public bool ShowScores + [SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _showScores; + + private void OnShowScoresChanged(bool oldValue, bool newValue) { - get => _showScores; - set + if (newValue) { - if (_showScores == value) - { - return; - } - - _showScores = value; - - if (value) - { - _players.SendPlayersPacket(true, true); - } - - _players.SendGeneralPacket(true, true); - _players.SendLocalizedMessage(value ? 1062777 : 1062778); // The dealer has enabled/disabled score display. - this.MarkDirty(); + _players.SendPlayersPacket(true, true); } + _players.SendGeneralPacket(true, true); + _players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display. } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public bool SpectatorVision + [SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _spectatorVision; + + private void OnSpectatorVisionChanged(bool oldValue, bool newValue) { - get => _spectatorVision; - set + if (_players.IsInGamePlayer(_players.DealerPosition)) { - if (_spectatorVision == value) - { - return; - } - - _spectatorVision = value; - - if (_players.IsInGamePlayer(_players.DealerPosition)) - { - _players.Dealer.NetState.SendMahjongGeneralInfo(this); - } - - _players.SendTilesPacket(false, true); - _players.SendLocalizedMessage(value ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. - InvalidateProperties(); - this.MarkDirty(); + _players.Dealer.NetState.SendMahjongGeneralInfo(this); } + _players.SendTilesPacket(false, true); + _players.SendLocalizedMessage(newValue ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. } private void BuildHorizontalWall( diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index 4eb01cbe0..e693c0aaa 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -93,16 +93,13 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - } + Hue = CraftResources.GetHue(_resource); } public override int PhysicalResistance => Resistances.Physical; diff --git a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs index a52b1a12d..07df233e9 100644 --- a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs +++ b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs @@ -275,8 +275,16 @@ public partial class BroadcastCrystal : Item [SerializationGenerator(0)] public partial class ReceiverCrystal : Item { + [SerializableField(0, fieldChanged: nameof(OnSenderChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private BroadcastCrystal _sender; + private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue) + { + oldValue?.RemoveReceiver(this); + newValue?.AddReceiver(this); + } + [Constructible] public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150; @@ -297,20 +305,6 @@ public partial class ReceiverCrystal : Item } } - [SerializableProperty(0, useField: nameof(_sender))] - [CommandProperty(AccessLevel.GameMaster)] - public BroadcastCrystal Sender - { - get => _sender; - set - { - _sender?.RemoveReceiver(this); - _sender = value; - value?.AddReceiver(this); - this.MarkDirty(); - } - } - public override void GetProperties(IPropertyList list) { base.GetProperties(list); diff --git a/Projects/UOContent/Items/Misc/MorphItem.cs b/Projects/UOContent/Items/Misc/MorphItem.cs index c5dd9c0de..7e5a74d8b 100644 --- a/Projects/UOContent/Items/Misc/MorphItem.cs +++ b/Projects/UOContent/Items/Misc/MorphItem.cs @@ -30,20 +30,24 @@ public partial class MorphItem : Item _outsideRange = outRange; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int OutsideRange + [SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _outsideRange; + + private bool AllowOutsideRangeChange(ref int value) { - get => _outsideRange; - set => _outsideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int InsideRange + [SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _insideRange; + + private bool AllowInsideRangeChange(ref int value) { - get => _insideRange; - set => _insideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/WarningItem.cs b/Projects/UOContent/Items/Misc/WarningItem.cs index 5d0b491a4..3779e4900 100644 --- a/Projects/UOContent/Items/Misc/WarningItem.cs +++ b/Projects/UOContent/Items/Misc/WarningItem.cs @@ -14,8 +14,16 @@ public partial class WarningItem : Item private TextDefinition _warningMessage; // Field 1 + [SerializableField(1, allowFieldChange: nameof(AllowRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private int _range; + private bool AllowRangeChange(ref int value) + { + value = Math.Min(value, 18); + return true; + } + [SerializableField(2)] private TimeSpan _resetDelay; @@ -39,18 +47,6 @@ public partial class WarningItem : Item _range = Math.Min(range, 18); } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1, useField: nameof(_range))] - public int Range - { - get => _range; - set - { - _range = Math.Min(value, 18); - this.MarkDirty(); - } - } - public virtual bool OnlyToTriggerer => false; public virtual int NeighborRange => 5; diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs index ce772da40..8170b286d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs @@ -16,22 +16,14 @@ public abstract partial class BaseIngot : Item, ICommodity public override double DefaultWeight => 0.1; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs index 3116d7136..6ae0c71c3 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs @@ -17,22 +17,14 @@ public abstract partial class BaseOre : Item _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs index fa63d902c..277ed569d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs @@ -14,22 +14,14 @@ public abstract partial class BaseScales : Item, ICommodity _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1053139; // dragon scales diff --git a/Projects/UOContent/Items/Resources/Masonry/Granite.cs b/Projects/UOContent/Items/Resources/Masonry/Granite.cs index ae4f215d7..089181ebe 100644 --- a/Projects/UOContent/Items/Resources/Masonry/Granite.cs +++ b/Projects/UOContent/Items/Resources/Masonry/Granite.cs @@ -15,22 +15,14 @@ public abstract partial class BaseGranite : Item public override double DefaultWeight => Core.ML ? 1.0 : 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1044607; // high quality granite diff --git a/Projects/UOContent/Items/Resources/Tailor/Hides.cs b/Projects/UOContent/Items/Resources/Tailor/Hides.cs index df6423fb2..d64eb2f0b 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Hides.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Hides.cs @@ -15,22 +15,14 @@ public abstract partial class BaseHides : Item, ICommodity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs index 173e2dae0..afb757e1f 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs @@ -15,22 +15,14 @@ public abstract partial class BaseLeather : Item, ICommodity public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs index 9ff0d8386..53c8077f1 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs @@ -21,22 +21,14 @@ public partial class Board : Item, ICommodity Hue = CraftResources.GetHue(resource); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } int ICommodity.DescriptionNumber diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs index 5d6062099..bf626c2fd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -25,16 +25,14 @@ public partial class MessageInABottle : Item public override int LabelNumber => 1041080; // a message in a bottle - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; } public static int GetRandomLevel() diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index f8450e63f..5374f8716 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -103,18 +103,20 @@ public partial class SOS : Item [CommandProperty(AccessLevel.GameMaster)] public bool IsAncient => _level >= 4; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, fieldChanged: nameof(OnLevelChanged), allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - UpdateHue(); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; + } + + private void OnLevelChanged(int oldValue, int newValue) + { + UpdateHue(); } public void UpdateHue() => Hue = IsAncient ? 0x481 : 0; diff --git a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs index 2873957d5..2a06f535a 100644 --- a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs +++ b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs @@ -28,22 +28,14 @@ public partial class Log : Item, ICommodity, IAxe public override double DefaultWeight => 2.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public virtual bool Axe(Mobile from, BaseAxe axe) => TryCreateBoards(from, 0, new Board()); diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 68b6f324a..14db7ac1e 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -47,36 +47,24 @@ public partial class RecallRune : Item } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public bool Marked + [SerializableField(2, fieldChanged: nameof(OnMarkedChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _marked; + + private void OnMarkedChanged(bool oldValue, bool newValue) { - get => _marked; - set - { - if (_marked != value) - { - _marked = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public Map TargetMap + [SerializableField(4, fieldChanged: nameof(OnTargetMapChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private Map _targetMap; + + private void OnTargetMapChanged(Map oldValue, Map newValue) { - get => _targetMap; - set - { - if (_targetMap != value) - { - _targetMap = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index f1ced7308..9447fddfa 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -133,28 +133,19 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem public virtual int BookOffset => 0; public virtual int BookCount => 64; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public ulong Content + [SerializableField(7, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ulong _content; + + private void OnContentChanged(ulong oldValue, ulong newValue) { - get => _content; - set + // This assignment will mark it as dirty + SpellCount = 0; + while (newValue > 0) { - if (_content != value) - { - _content = value; - - // This assignment will mark it as dirty - SpellCount = 0; - - while (value > 0) - { - _spellCount += (int)(value & 0x1); - value >>= 1; - } - - InvalidateProperties(); - } + _spellCount += (int)(newValue & 0x1); + newValue >>= 1; } } diff --git a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs index dbb8df68c..e4d486d10 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs @@ -62,17 +62,15 @@ public partial class RepairDeed : Item public override bool DisplayLootType => false; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public double SkillLevel + [SerializableField(1, allowFieldChange: nameof(AllowSkillLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillLevel; + + private bool AllowSkillLevelChange(ref double value) { - get => _skillLevel; - set - { - _skillLevel = Math.Clamp(value, 0, 120.0); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, 120.0); + return true; } public override void AddNameProperty(IPropertyList list) diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index b01416211..f7716a4a1 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -74,16 +74,13 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime LastReplenished + [SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _lastReplenished; + + private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue) { - get => _lastReplenished; - set - { - _lastReplenished = value; - CheckReplenishUses(); - } + CheckReplenishUses(); } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs index 6b557b7be..e35cca471 100644 --- a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs +++ b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs @@ -41,20 +41,13 @@ namespace Server.Items public virtual bool AllowDyables => true; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int DyedHue - { - get => _dyedHue; - set - { - if (_redyable) - { - _dyedHue = value; - Hue = value; - } - } - } + [SerializableField(2, allowFieldChange: nameof(AllowDyedHueChange), fieldChanged: nameof(OnDyedHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _dyedHue; + + private bool AllowDyedHueChange(ref int value) => _redyable; + + private void OnDyedHueChanged(int oldValue, int newValue) => Hue = newValue; // Three metallic tubs now. public virtual bool MetallicHues => false; diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs index 6010ec376..595b6b11d 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs @@ -60,17 +60,14 @@ public abstract partial class BaseRunicTool : BaseTool public BaseRunicTool(CraftResource resource, int uses, int itemID) : base(uses, itemID) => _resource = resource; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(_resource); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index b66056831..404fc9012 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -54,17 +54,15 @@ public partial class FountainOfLife : BaseAddonContainer public override int DefaultDropSound => 66; public override int DefaultMaxItems => 125; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, MaxCharges); + return true; } public override bool OnDragLift(Mobile from) => false; @@ -183,16 +181,14 @@ public partial class FountainOfLifeDeed : BaseAddonContainerDeed public override int LabelNumber => 1075197; // Fountain of Life public override BaseAddonContainer Addon => new FountainOfLife(_charges); - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, FountainOfLife.MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, FountainOfLife.MaxCharges); + return true; } } diff --git a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs index 6a7e5af6f..fabb756d8 100644 --- a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs +++ b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs @@ -14,12 +14,14 @@ public abstract partial class BaseFruitTreeAddon : BaseAddon public abstract override BaseAddonDeed Deed { get; } public abstract Item Fruit { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Fruits + [SerializableField(0, allowFieldChange: nameof(AllowFruitsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fruits; + + private bool AllowFruitsChange(ref int value) { - get => _fruits; - set => _fruits = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } public override void OnComponentUsed(AddonComponent c, Mobile from) diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index c24985f9f..59e2b8277 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -146,34 +146,24 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Rectangle2D PlotBounds - { - get => _plotBounds; - set - { - _plotBounds = value; + [SerializableField(3, fieldChanged: nameof(OnPlotBoundsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Rectangle2D _plotBounds; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotBoundsChanged(Rectangle2D oldValue, Rectangle2D newValue) + { + InvalidateRegion(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Map PlotFacet - { - get => _plotFacet; - set - { - _plotFacet = value; + [SerializableField(4, fieldChanged: nameof(OnPlotFacetChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Map _plotFacet; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotFacetChanged(Map oldValue, Map newValue) + { + InvalidateRegion(); } [CommandProperty(AccessLevel.GameMaster)] @@ -190,17 +180,15 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public int TicketPrice + [SerializableField(6, allowFieldChange: nameof(AllowTicketPriceChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private int _ticketPrice; + + private bool AllowTicketPriceChange(ref int value) { - get => _ticketPrice; - set - { - _ticketPrice = Math.Max(0, value); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(0, value); + return true; } public override string DefaultName => "a house raffle stone"; diff --git a/Projects/UOContent/Items/Special/MonsterStatuette.cs b/Projects/UOContent/Items/Special/MonsterStatuette.cs index 8d49dd0e4..fd26a0060 100644 --- a/Projects/UOContent/Items/Special/MonsterStatuette.cs +++ b/Projects/UOContent/Items/Special/MonsterStatuette.cs @@ -162,21 +162,15 @@ public partial class MonsterStatuette : Item, IRewardItem, IGumpToggleItem _ => fallback }; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public MonsterStatuetteType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private MonsterStatuetteType _type; + + private void OnTypeChanged(MonsterStatuetteType oldValue, MonsterStatuetteType newValue) { - get => _type; - set - { - _type = value; - ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; - - Hue = GetStatuetteHue(_type, Hue); - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; + Hue = GetStatuetteHue(_type, Hue); } public override int LabelNumber => MonsterStatuetteInfo.GetInfo(_type).LabelNumber; diff --git a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs index f2772b342..9441fd5c7 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs @@ -36,50 +36,41 @@ public partial class BagOfSending : Item, TranslocationItem public override int LabelNumber => 1054104; // a bag of sending - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue - { - get => _bagOfSendingHue; - set - { - _bagOfSendingHue = value; + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + { + Hue = newValue switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(2, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index a76833e30..070a802b1 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -29,30 +29,26 @@ public partial class BallOfSummoning : Item, TranslocationItem public override double DefaultWeight => 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(2)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 2f82791c2..1d508c4ac 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -27,30 +27,26 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Special/SoulStone.cs b/Projects/UOContent/Items/Special/SoulStone.cs index bcad4a749..d26f6792c 100644 --- a/Projects/UOContent/Items/Special/SoulStone.cs +++ b/Projects/UOContent/Items/Special/SoulStone.cs @@ -83,20 +83,14 @@ public partial class SoulStone : Item, ISecurable } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster)] - public double SkillValue + [SerializableField(6, fieldChanged: nameof(OnSkillValueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillValue; + + private void OnSkillValueChanged(double oldValue, double newValue) { - get => _skillValue; - set - { - _skillValue = value; - - ItemID = IsEmpty ? _inactiveItemID : _activeItemID; - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = IsEmpty ? _inactiveItemID : _activeItemID; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Suits/BaseSuit.cs b/Projects/UOContent/Items/Suits/BaseSuit.cs index 018c010a2..dd1eb00f6 100644 --- a/Projects/UOContent/Items/Suits/BaseSuit.cs +++ b/Projects/UOContent/Items/Suits/BaseSuit.cs @@ -17,20 +17,9 @@ public abstract partial class BaseSuit : Item public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - public AccessLevel AccessLevel - { - get => _accessLevel; - set - { - var oldAccessLevel = _accessLevel; - _accessLevel = value; - InvalidateProperties(); - this.MarkDirty(); - - OnAccessLevelChanged(oldAccessLevel, _accessLevel); - } - } + [SerializableField(0, fieldChanged: nameof(OnAccessLevelChanged))] + [InvalidateProperties] + private AccessLevel _accessLevel; public virtual void OnAccessLevelChanged(AccessLevel oldAccessLevel, AccessLevel accessLevel) { diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index 3853d4594..b3f0c2ed4 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -279,23 +279,17 @@ public partial class BaseTalisman : Item, IAosItem public override int LabelNumber => 1071023; // Talisman public virtual bool ForceShowName => false; // used to override default summoner/removal name - [SerializableProperty(10)] + [SerializableField(10, fieldChanged: nameof(OnChargesChanged))] [SaveFlag(nameof(ShouldSerializeCharges))] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private void OnChargesChanged(int oldValue, int newValue) { - get => _charges; - set + if (_chargeTime > 0) { - _charges = value; - - if (_chargeTime > 0) - { - StartTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + StartTimer(); } } diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 762a0be9c..8516e6678 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -36,16 +36,14 @@ public partial class ShardPoller : Item Movable = false; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public string Title + [SerializableField(0, allowFieldChange: nameof(AllowTitleChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private string _title; + + private bool AllowTitleChange(ref string value) { - get => _title; - set - { - _title = ShardPollPrompt.UrlToHref(value); - this.MarkDirty(); - } + value = ShardPollPrompt.UrlToHref(value); + return true; } [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -54,31 +52,20 @@ public partial class ShardPoller : Item ? TimeSpan.Zero : Utility.Max(StartTime + Duration - Core.Now, TimeSpan.Zero); - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public bool Active + [SerializableField(3, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private bool _active; + + private void OnActiveChanged(bool oldValue, bool newValue) { - get => _active; - set + if (_active) { - if (_active == value) - { - return; - } - - _active = value; - - if (_active) - { - StartTime = Core.Now; - _activePollers.Add(this); - } - else - { - _activePollers.Remove(this); - } - - this.MarkDirty(); + StartTime = Core.Now; + _activePollers.Add(this); + } + else + { + _activePollers.Remove(this); } } @@ -250,15 +237,12 @@ public partial class ShardPollOption } } - [SerializableProperty(0)] - public string Title + [SerializableField(0, fieldChanged: nameof(OnTitleChanged))] + private string _title; + + private void OnTitleChanged(string oldValue, string newValue) { - get => _title; - set - { - _title = value; - _lineBreaks = -1; - } + _lineBreaks = -1; } public int Votes => Voters.Length; diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index b43c34272..32d0883cc 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -43,18 +43,14 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; + [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] [DeltaDateTime] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime NextWoolTime + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _nextWoolTime; + + private void OnNextWoolTimeChanged(DateTime oldValue, DateTime newValue) { - get => _nextWoolTime; - set - { - _nextWoolTime = value; - SheepBody(); - this.MarkDirty(); - } + SheepBody(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index e2018ab14..f6dc13b40 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -40,43 +40,27 @@ namespace Server.Mobiles public override double DefaultWeight => 1.0; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int MountedID - { - get => _mountedID; - set - { - if (_mountedID != value) - { - _mountedID = value; + [SerializableField(2, fieldChanged: nameof(OnMountedIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _mountedID; - if (_rider != null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnMountedIDChanged(int oldValue, int newValue) + { + if (_rider != null) + { + ItemID = newValue; } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int RegularID - { - get => _regularID; - set - { - if (_regularID != value) - { - _regularID = value; + [SerializableField(3, fieldChanged: nameof(OnRegularIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _regularID; - if (_rider == null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnRegularIDChanged(int oldValue, int newValue) + { + if (_rider == null) + { + ItemID = newValue; } } @@ -127,17 +111,15 @@ namespace Server.Mobiles private bool ShouldSerializeRider() => _rider != null; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(5)] + [SerializableField(5, allowFieldChange: nameof(AllowStepsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] [SaveFlag(nameof(ShouldSerializeSteps))] - public int Steps + private int _steps; + + private bool AllowStepsChange(ref int value) { - get => _steps; - set - { - _steps = Math.Clamp(value, 0, StepsMax); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, StepsMax); + return true; } private bool ShouldSerializeSteps() => _steps != StepsMax; diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs index 69bd9ce56..19a061af2 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs @@ -62,48 +62,37 @@ namespace Server.Mobiles [SerializableField(3)] private int _bardingHP; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(2)] - public bool HasBarding - { - get => _hasBarding; - set - { - _hasBarding = value; + [SerializableField(2, fieldChanged: nameof(OnHasBardingChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _hasBarding; - if (_hasBarding) - { - Hue = CraftResources.GetHue(_bardingResource); - Body = 0x31F; - ItemID = 0x3EBE; - } - else - { - Hue = 0x851; - Body = 0x31A; - ItemID = 0x3EBD; - } - InvalidateProperties(); - this.MarkDirty(); + private void OnHasBardingChanged(bool oldValue, bool newValue) + { + if (_hasBarding) + { + Hue = CraftResources.GetHue(_bardingResource); + Body = 0x31F; + ItemID = 0x3EBE; + } + else + { + Hue = 0x851; + Body = 0x31A; + ItemID = 0x3EBD; } } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(4)] - public CraftResource BardingResource + [SerializableField(4, fieldChanged: nameof(OnBardingResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _bardingResource; + + private void OnBardingResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _bardingResource; - set + if (_hasBarding) { - _bardingResource = value; - - if (_hasBarding) - { - Hue = CraftResources.GetHue(value); - } - - InvalidateProperties(); - this.MarkDirty(); + Hue = CraftResources.GetHue(newValue); } } diff --git a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs index e3fbadd97..3f96e5d36 100644 --- a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs +++ b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs @@ -23,19 +23,14 @@ public partial class BaseHire : BaseCreature public int GoldOnDeath { get; set; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool IsHired - { - get => _isHired; - set - { - _isHired = value; + [SerializableField(1, fieldChanged: nameof(OnIsHiredChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _isHired; - Delta(MobileDelta.Noto); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnIsHiredChanged(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); } public BaseHire(AIType AI) : base(AI, FightMode.Aggressor) diff --git a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs index d80c60dcc..bbbc3ff6f 100644 --- a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs +++ b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs @@ -148,18 +148,13 @@ public partial class PlayerBarkeeper : BaseVendor LoadSBInfo(); } - [SerializableProperty(0)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerBarkeepers.Remove(this); - value?.PlayerBarkeepers.Add(this); + [SerializableField(0, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerBarkeepers.Remove(this); + newValue?.PlayerBarkeepers.Add(this); } public override bool IsActiveBuyer => false; diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 2ff6eb812..5b878def2 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -94,18 +94,13 @@ public partial class PlayerVendor : Mobile public PlayerVendorPlaceholder Placeholder { get; set; } - [SerializableProperty(2)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerVendors.Remove(this); - value?.PlayerVendors.Add(this); + [SerializableField(2, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerVendors.Remove(this); + newValue?.PlayerVendors.Add(this); } public int ChargePerDay diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 14af3656a..99d1446e6 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -32,18 +32,20 @@ public partial class VendorItem public string FormattedPrice => Core.ML ? Price.ToString("N0", CultureInfo.GetCultureInfo("en-US")) : Price.ToString(); - [SerializableProperty(2)] - public string Description - { - get => _description; - set - { - _description = value ?? ""; + [SerializableField(2, fieldChanged: nameof(OnDescriptionChanged), allowFieldChange: nameof(AllowDescriptionChange))] + private string _description; - if (Valid) - { - Item.InvalidateProperties(); - } + private bool AllowDescriptionChange(ref string value) + { + value = value ?? ""; + return true; + } + + private void OnDescriptionChanged(string oldValue, string newValue) + { + if (Valid) + { + Item.InvalidateProperties(); } } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 2082056da..9b2d4cc37 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -136,31 +136,23 @@ namespace Server.Multis } } + [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] [DeltaDateTime] - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime TimeOfDecay + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _timeOfDecay; + + private void OnTimeOfDecayChanged(DateTime oldValue, DateTime newValue) { - get => _timeOfDecay; - set - { - _timeOfDecay = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } - [SerializableProperty(10)] - [CommandProperty(AccessLevel.GameMaster)] - public string ShipName + [SerializableField(10, fieldChanged: nameof(OnShipNameChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _shipName; + + private void OnShipNameChanged(string oldValue, string newValue) { - get => _shipName; - set - { - _shipName = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } [CommandProperty(AccessLevel.GameMaster)] From b992c7b955468512b31ab104a8f3d90b662eb11d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:29:27 -0700 Subject: [PATCH 08/20] docs: update serialization docs and skills for generator v4 (#2588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes. **Updated surface, everywhere it was referenced:** - `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional). - `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes. - New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails. - `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express. - `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them). **Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9. **Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern. Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`. --- CLAUDE.md | 2 +- .../claude-skills/modernuo-serialization.md | 96 +++++++----- dev-docs/claude-skills/modernuo-timers.md | 13 +- .../runuo-migration-docs/02-serialization.md | 21 ++- dev-docs/runuo-migration-docs/03-timers.md | 2 +- dev-docs/serialization.md | 140 +++++++++++++++--- dev-docs/timers.md | 8 +- 7 files changed, 219 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 849ff3175..c77d39e19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct -9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` +9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md` 10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code diff --git a/dev-docs/claude-skills/modernuo-serialization.md b/dev-docs/claude-skills/modernuo-serialization.md index c96244656..91e3d5e95 100644 --- a/dev-docs/claude-skills/modernuo-serialization.md +++ b/dev-docs/claude-skills/modernuo-serialization.md @@ -40,21 +40,31 @@ public partial class MyItem : Item { } public partial class MigratedItem : Item { } ``` -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] Applied to `_camelCase` private fields. Generates `PascalCase` property. - `index`: Serialization order (0+) -- `setter`: Access level -- `"private"`, `"internal"`, or omit for public -- `saveIf`: Condition method name for conditional serialization +- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public +- `isVirtual`: Generate a virtual property +- `fieldChanged`: `nameof` of `void Method(T oldValue, T newValue)`, invoked by the generated setter after assignment +- `allowFieldChange`: `nameof` of `bool Method(ref T value)`, invoked before assignment -- coerce through the `ref` parameter or return `false` to reject + +Generated setter pipeline: equality check → `allowFieldChange` → assignment → `MarkDirty` → `InvalidateProperties` (if declared) → `fieldChanged`. The field still holds the old value while the gate runs. Hooks require a generated setter (SG3018 on readonly/setterless fields); a missing or wrong-shaped named method is SG3015. ```csharp -[SerializableField(0)] +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] [SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] private int _charges; -// Generates: public int Charges { get; set; } + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); + return true; +} ``` ### [SerializableProperty(index, useField)] -Applied to properties with custom get/set logic. +Applied to properties with **custom getters** (fallback defaults, lazy/self-healing reads) or setter semantics the field hooks cannot express. For setters that only coerce, veto, or run post-change side effects, use `[SerializableField]` with `allowFieldChange`/`fieldChanged` instead. - `index`: Serialization order - `useField`: Backing field name if auto-detection fails @@ -63,12 +73,12 @@ Applied to properties with custom get/set logic. [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; InvalidateProperties(); - this.MarkDirty(); + this.MarkDirty(); // REQUIRED in custom setters } } ``` @@ -89,8 +99,11 @@ Exposes field to `[Props` gump for in-game editing. ### [EncodedInt] Variable-length int encoding (saves space for small values). +### [AnchoredDateTime] +Stores the absolute UTC instant; shifted by downtime at load so remaining time is preserved. Byte-stable across idle saves. Prefer for deadlines/elapsed-while-running values. + ### [DeltaDateTime] -Stores DateTime as offset from current time (handles server restarts). +Stores DateTime as offset from current time (handles server restarts). Legacy: rewrites bytes every save; prefer `[AnchoredDateTime]` for new fields. Converting between the two changes the wire format (version bump). ### [InternString] Interns strings to reduce memory for repeated values. @@ -128,28 +141,32 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] -Custom timer deserialization. Timer is saved as remaining TimeSpan. +### [DeserializeTimer(nameof(Method), wallClock)] +Required on every serializable `Timer` member (SG3008 otherwise). By default the next tick is stored as **anchored time** (downtime does not consume the remaining delay; idle saves byte-stable); `wallClock: true` stores an absolute deadline instead (delay negative if it passed during downtime). The method -- `void Method(TimeSpan delay)` -- is invoked **only when a timer was running at save**; there is no sentinel to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] -Conditional serialization -- skip fields with default values. +Switching a timer between drifting and `wallClock` changes the wire format: bump the class version and add `MigrateFrom` -- the old content struct exposes `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer ran). + +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +On the serializable field/property itself. Conditional serialization -- skip fields with default values. Second method optional; when omitted, the field keeps its default at load. ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -216,28 +233,35 @@ public partial class ChargedItem : Item } ``` -### Item with Custom Properties +### Item with Setter Hooks (coerce + side effects) ```csharp [SerializationGenerator(2)] public partial class BagOfSending : Item { - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; + + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) { - get => _bagOfSendingHue; - set + Hue = newValue switch { - _bagOfSendingHue = value; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + } + + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) + { + value = Math.Clamp(value, 0, MaxCharges); + return true; } } ``` @@ -328,11 +352,13 @@ public partial class MagicGem ## Real Examples - Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs` - Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs` -- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Setter hooks (allowFieldChange + fieldChanged): `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Custom getters (era fallbacks, the [SerializableProperty] use case): `Projects/UOContent/Items/Weapons/BaseWeapon.cs` - Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs` -- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (wall-clock): `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (drifting/anchored + timer MigrateFrom): `Projects/UOContent/Items/Lights/BaseLight.cs` - Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` -- Conditional serialization: `Projects/Server/Items/Container.cs` +- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs` ## Version Migration Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`: diff --git a/dev-docs/claude-skills/modernuo-timers.md b/dev-docs/claude-skills/modernuo-timers.md index 2c5c8168e..f366b3569 100644 --- a/dev-docs/claude-skills/modernuo-timers.md +++ b/dev-docs/claude-skills/modernuo-timers.md @@ -147,18 +147,27 @@ public partial class DecayingItem : Item } ``` -### [DeserializeTimerField] Pattern (for Timer fields) +### [DeserializeTimer] Pattern (for Timer fields) +Required on every serializable `Timer` member. Drifting by default: the next tick is stored +as anchored time, so server downtime does not consume the remaining delay. Use +`wallClock: true` for absolute deadlines (delay is negative if it passed during downtime). +The method is invoked **only when a timer was running at save** — no sentinel to check. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class's `[SerializationGenerator]` version and add a `MigrateFrom` (the old content +struct exposes `XxxDelay`, `TimeSpan.MinValue` when no timer ran). + ### Custom Timer Class (When You Need Complex Logic) ```csharp private class DecayTimer : Timer diff --git a/dev-docs/runuo-migration-docs/02-serialization.md b/dev-docs/runuo-migration-docs/02-serialization.md index 1470792af..666ebfdb4 100644 --- a/dev-docs/runuo-migration-docs/02-serialization.md +++ b/dev-docs/runuo-migration-docs/02-serialization.md @@ -457,16 +457,24 @@ set ``` Without this, changes won't be saved. +Most RunUO custom setters only clamp the value or run side effects after assignment. Those +convert to a plain `[SerializableField]` with the `allowFieldChange`/`fieldChanged` hooks, +which handle the equality check and `MarkDirty()` for you -- reserve `[SerializableProperty]` +for custom getters (see `dev-docs/serialization.md`). + ### 3. Field Ordering The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version. ### 4. Conditional Serialization -Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values: +Use `[SaveFlag]` on the serializable field to skip default values (the second method is +optional -- omit it and the field keeps its default at load): ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -479,12 +487,15 @@ private List _followers; ``` ### 6. DateTime Fields -Use `[DeltaDateTime]` to survive server restarts: +Use `[AnchoredDateTime]` to survive server restarts -- the value is shifted by downtime at +load, so the remaining time is preserved and idle saves stay byte-stable: ```csharp -[DeltaDateTime] +[AnchoredDateTime] [SerializableField(0)] private DateTime _expireTime; ``` +(`[DeltaDateTime]` is the legacy equivalent; it rewrites bytes on every save. Converting an +existing field between the two changes the wire format and requires a version bump.) ### 7. Keeping Manual Serialization (Rare) Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first. diff --git a/dev-docs/runuo-migration-docs/03-timers.md b/dev-docs/runuo-migration-docs/03-timers.md index 29f3d1282..13596a079 100644 --- a/dev-docs/runuo-migration-docs/03-timers.md +++ b/dev-docs/runuo-migration-docs/03-timers.md @@ -305,7 +305,7 @@ In RunUO, timers are commonly started in `Deserialize()`. In ModernUO, use `[Aft `_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed. ### 4. Timer.DelayCall Still Exists -`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for `[DeserializeTimerField]`) or state-carrying overloads. +`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for a serialized timer field with `[DeserializeTimer]`) or state-carrying overloads. ### 5. Custom Timer Classes Are Still Possible For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases. diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 151d9d717..059fbff69 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -117,7 +117,7 @@ public partial class MyItem : Item { } See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance. -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] **Target**: Private field (`_camelCase`) **Generates**: Public `PascalCase` property with get/set @@ -125,8 +125,11 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g | Parameter | Type | Default | Description | |---|---|---|---| | `index` | `int` | Required | Serialization order (0-based) | -| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter | -| `saveIf` | `string` | `null` | Method name returning bool for conditional save | +| `getter` | `string` | `"public"` | Getter accessibility | +| `setter` | `string` | `"public"` | `"private"` or `"internal"` to restrict setter | +| `isVirtual` | `bool` | `false` | Generate a `virtual` property | +| `fieldChanged` | `string` | `null` | `nameof` of a `void Method(T oldValue, T newValue)` invoked by the generated setter after assignment | +| `allowFieldChange` | `string` | `null` | `nameof` of a `bool Method(ref T value)` invoked before assignment; coerce the value through the `ref` parameter, or return `false` to reject the change | ```csharp [SerializableField(0)] // Public property @@ -144,14 +147,57 @@ The generated property for `_charges` would be: public int Charges { get => _charges; - set { _charges = value; this.MarkDirty(); } + set + { + if (value != _charges) + { + _charges = value; + this.MarkDirty(); + } + } } ``` +**Setter hooks** replace most hand-written `[SerializableProperty]` setters. The generated +pipeline is: equality check → `allowFieldChange` (coerce/veto) → assignment → `MarkDirty` → +`InvalidateProperties` (if declared) → `fieldChanged`. The gate runs before assignment, so +the field itself still holds the old value inside it. + +```csharp +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] +[SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] +private int _charges; + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); // coerce, or return false to veto + return true; +} + +[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))] +private Mobile _owner; + +// oldValue makes unsubscribe/resubscribe patterns trivial +private void OnOwnerChanged(Mobile oldValue, Mobile newValue) +{ + oldValue?.Followers.Remove(this); + newValue?.Followers.Add(this); +} +``` + +Both hooks require a generated setter — declaring one on a `readonly` field or with +`setter: null` is a compile-time error (SG3018), and a named method that is missing or has +the wrong signature is too (SG3015). + ### [SerializableProperty(index, useField)] **Target**: Property with custom get/set logic -**Use when**: You need non-trivial getter/setter logic +**Use when**: You need a **custom getter** (fallback defaults, lazy or self-healing reads) +or setter semantics the field hooks cannot express (work that must run on *equal* +assignment, pre-assignment state capture). For setters that only coerce, veto, or run +post-change side effects, prefer `[SerializableField]` with `allowFieldChange`/`fieldChanged` +instead — the generated setter handles equality, `MarkDirty`, and ordering for you. | Parameter | Type | Default | Description | |---|---|---|---| @@ -163,7 +209,7 @@ public int Charges [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; @@ -173,6 +219,10 @@ public int MaxItems } ``` +Note: the `fieldChanged`/`allowFieldChange` hooks are `[SerializableField]` arguments and +cannot be declared on a `[SerializableProperty]` — its setter is your own code, so call your +methods from the setter directly. + ### [InvalidateProperties] **Target**: `[SerializableField]`-decorated field @@ -208,12 +258,32 @@ Overloads: Best for fields that are usually small values (counts, IDs, indexes). +### [AnchoredDateTime] + +**Target**: `DateTime` field +**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime +between the save and the load (using the save-start anchor in the save's index file), so +server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +sentinels pass through unshifted. + +Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the +stored bytes do not change on every save when the value is unchanged, keeping idle saves +byte-stable. + +```csharp +[AnchoredDateTime] +[SerializableField(0)] +private DateTime _expireTime; +``` + ### [DeltaDateTime] **Target**: `DateTime` field **Effect**: Stores as offset from current time rather than absolute timestamp. -This ensures timers and expiration dates survive server restarts correctly. +Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the +value has not changed. Prefer `[AnchoredDateTime]` for new fields; converting an existing +field between the two changes the wire format and requires a version bump. ```csharp [DeltaDateTime] @@ -289,40 +359,76 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] +### [DeserializeTimer(nameof(Method), wallClock)] -**Target**: Method taking `TimeSpan` parameter -**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay. +**Target**: `Timer`-typed `[SerializableField]` or `[SerializableProperty]` member +**Effect**: Declares how the timer is stored and restored. Required on every serializable +timer (SG3008 otherwise). + +By default the timer's next tick is stored as **anchored time**: server downtime does not +consume the remaining delay, and idle saves are byte-stable. Pass `wallClock: true` to store +an absolute deadline instead (the delay is then negative when the deadline passed during +downtime). + +The named method — `void Method(TimeSpan delay)` — is invoked **only when a timer was +actually running at save**, with the remaining delay. There is no sentinel value to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] -private void DeserializeDecayTimer(TimeSpan delay) +private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete); +``` + +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class version and add a `MigrateFrom`. The old-version content struct exposes the +timer's `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer +was running): + +```csharp +private void MigrateFrom(V3Content content) { - _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +**Target**: the serializable field or property itself **Conditional serialization** -- skip fields that have their default value. +The first method (`bool Method()`) decides whether the value is written. The optional second +method (returning the field's type, no parameters) supplies the value at load when it was +not written; when omitted, the field keeps its default value. + +```csharp +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))] +private int _charges; + +private bool ShouldSerializeCharges() => _charges != -1; + +private int ChargesDefaultValue() => -1; +``` + +Works on `[SerializableProperty]` members the same way: + ```csharp [EncodedInt] [SerializableProperty(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] public int MaxItems { get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; this.MarkDirty(); } } -[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` diff --git a/dev-docs/timers.md b/dev-docs/timers.md index c10f31bd6..2c051cca6 100644 --- a/dev-docs/timers.md +++ b/dev-docs/timers.md @@ -188,15 +188,19 @@ public partial class TimedItem : Item ``` ### Pattern 4: Serializable Timer Field +Every serializable `Timer` member declares `[DeserializeTimer(nameof(Method))]` on the +field. By default the next tick is stored as anchored time (downtime does not consume the +remaining delay); pass `wallClock: true` for absolute deadlines. The method runs **only when +a timer was running at save**, with the remaining delay. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) { _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); } public void BeginDecay(TimeSpan delay) From 2935eafe2476d427865b2e9f0b7eb8be5a9907d5 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:34:43 -0700 Subject: [PATCH 09/20] feat: convert all delta-time serialization to anchored time (#2589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save. The answer to "is it possible everywhere": **yes** — including the one case that looked impossible. ## The GenericPersistence problem, solved `GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected. ## Converted - **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`. - **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`. - **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version. **Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them. ## Verification - Build 0 errors / 0 warnings; **837 + 708 tests green**. - Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched. - **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties). ## Notes for review - `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes. - BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes. ## Enforcement `WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract. --- .../AnchoredItemSerializationTests.cs | 76 +++++++ Projects/Server/Items/Item.cs | 29 ++- Projects/Server/Mobiles/Mobile.cs | 24 ++- Projects/Server/Serialization/BufferWriter.cs | 1 + .../Serialization/GenericEntityPersistence.cs | 4 + .../Serialization/GenericPersistence.cs | 8 +- .../Server/Serialization/IGenericReader.cs | 6 + .../Server/Serialization/IGenericWriter.cs | 3 + Projects/Server/World/World.cs | 9 + .../Engines/CannedEvil/ChampionSpawn.cs | 36 +++- .../Engines/CannedEvil/StarRoomGate.cs | 10 +- .../UOContent/Engines/Ethics/Core/Player.cs | 15 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 9 +- .../Engines/ML Quests/MLQuestPersistence.cs | 2 +- .../ML Quests/Objectives/BaseObjective.cs | 4 +- .../Engines/Stealables/StealableArtifacts.cs | 6 +- .../Engines/Virtues/VirtueContext.cs | 35 +++- .../Items/Misc/Corpses/Corpse.Migrations.cs | 179 +++++++++++++++++ .../UOContent/Items/Misc/Corpses/Corpse.cs | 150 +------------- ....Engines.CannedEvil.ChampionSpawn.v11.json | 189 ++++++++++++++++++ ...rver.Engines.Virtues.VirtueContext.v1.json | 119 +++++++++++ .../Migrations/Server.Ethics.Player.v2.json | 50 +++++ .../Migrations/Server.Items.Corpse.v19.json | 137 +++++++++++++ ...r.Items.PuzzleChestSolutionAndTime.v1.json | 14 ++ .../Server.Items.StarRoomGate.v2.json | 22 ++ .../Server.Items.TransientItem.v2.json | 14 ++ .../Server.Mobiles.PlayerVendor.v4.json | 62 ++++++ .../Server.Mobiles.RentedVendor.v1.json | 62 ++++++ .../Migrations/Server.Mobiles.Sheep.v1.json | 14 ++ .../Migrations/Server.Multis.BaseBoat.v5.json | 73 +++++++ .../Migrations/Server.Multis.BaseCamp.v2.json | 34 ++++ .../Server.Spells.Fifth.PoisonField.v1.json | 19 ++ ...Server.Spells.Fourth.FireFieldItem.v1.json | 27 +++ .../Server.Spells.Seventh.EnergyField.v2.json | 19 ++ .../Server.Spells.Sixth.ParalyzeField.v1.json | 19 ++ .../Server.Spells.Third.WallOfStone.v1.json | 19 ++ .../UOContent/Mobiles/Animals/Misc/Sheep.cs | 9 +- Projects/UOContent/Mobiles/BaseCreature.cs | 6 +- .../UOContent/Mobiles/Vendors/PlayerVendor.cs | 15 +- .../UOContent/Mobiles/Vendors/RentedVendor.cs | 15 +- .../Mobiles/Vendors/VendorInventory.cs | 6 +- Projects/UOContent/Multis/Boats/BaseBoat.cs | 19 +- Projects/UOContent/Multis/Camps/BaseCamp.cs | 11 +- .../UOContent/Spells/Fifth/PoisonField.cs | 10 +- Projects/UOContent/Spells/Fourth/FireField.cs | 11 +- .../UOContent/Spells/Seventh/EnergyField.cs | 10 +- .../UOContent/Spells/Sixth/ParalyzeField.cs | 10 +- .../Spellweaving/Items/TransientItem.cs | 9 +- .../UOContent/Spells/Third/WallOfStone.cs | 10 +- 49 files changed, 1417 insertions(+), 223 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs create mode 100644 Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs create mode 100644 Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json create mode 100644 Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Ethics.Player.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Items.Corpse.v19.json create mode 100644 Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json create mode 100644 Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs new file mode 100644 index 000000000..7889c57c8 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -0,0 +1,76 @@ +using System; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class AnchoredItemSerializationTests +{ + private static byte[] SerializeItem(Item item) + { + var writer = new BufferWriter(new byte[256], true); + item.Serialize(writer); + return writer.Buffer[..(int)writer.Position]; + } + + /// + /// Item v11 stores LastMoved and DecayResetTime as anchored time: the serialized bytes + /// are a function of item state only, not of when the save runs. Pre-v11 stored + /// minutes-since-moved and delta time, which rewrote the bytes on every save. + /// + [Fact] + public void ItemBytes_AreStable_AcrossSavesAtDifferentTimes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(120, 100, 0), Map.Felucca); + item.RestartDecay(); + + var first = SerializeItem(item); + + // A save hours later, with no state change, must produce identical bytes. + Core._now = start + TimeSpan.FromHours(5); + var second = SerializeItem(item); + + Assert.Equal(first, second); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Pre-v11 LastMoved was stored at whole-minute precision relative to the save time and + /// could never round-trip exactly. Anchored storage is absolute and exact. + /// + [Fact] + public void LastMovedAndDecayReset_RoundTripExactly() + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); + + // Sub-minute precision that the old minutes encoding would have destroyed. + var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); + item.LastMoved = moved; + + item.RestartDecay(); + var decayReset = item.DecayResetTime; + Assert.NotEqual(default(DateTime), decayReset); + + var bytes = SerializeItem(item); + + var restored = new Item((Serial)0x7ffff123u); + restored.Deserialize(new BufferReader(bytes)); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(decayReset, restored.DecayResetTime); + + item.Delete(); + } +} diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index a2b63a821..13f249f2f 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -863,7 +863,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(10); // version + writer.Write(11); // version var flags = SaveFlag.None; @@ -1015,19 +1015,13 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - /* begin last moved time optimization */ - var ticks = LastMoved.Ticks; - var now = Core.Now.Ticks; - - var minutes = new TimeSpan(now - ticks).TotalMinutes; - - writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); - /* end */ + // Anchored: shifted by downtime at load, so time-since-moved is preserved and the + // bytes are stable across saves while the item does not move. + writer.WriteAnchoredTime(LastMoved); if (GetSaveFlag(flags, SaveFlag.DecayReset)) { - //TODO Use WriteAnchoredTime once the save-time anchor is ported - writer.WriteDeltaTime(info.m_DecayReset); + writer.WriteAnchoredTime(info.m_DecayReset); } if (GetSaveFlag(flags, SaveFlag.Direction)) @@ -2772,6 +2766,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { + case 11: case 10: case 9: case 8: @@ -2780,7 +2775,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var flags = (SaveFlag)reader.ReadInt(); - if (version < 7) + if (version >= 11) + { + LastMoved = reader.ReadAnchoredTime(); + } + else if (version < 7) { LastMoved = reader.ReadDeltaTime(); } @@ -2800,10 +2799,10 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - var reset = reader.ReadDeltaTime(); + var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - // LastMoved is stored at whole-minute precision; keep the stamp only - // while it still extends the deadline. + // Pre-v11 LastMoved was stored at whole-minute precision; keep the + // stamp only while it still extends the deadline. if (reset > LastMoved) { DecayResetTime = reset; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 0e88449e3..fb028b9d7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2324,11 +2324,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual void Serialize(IGenericWriter writer) { - writer.Write(37); // version + writer.Write(38); // version - writer.WriteDeltaTime(LastStrGain); - writer.WriteDeltaTime(LastIntGain); - writer.WriteDeltaTime(LastDexGain); + writer.WriteAnchoredTime(LastStrGain); + writer.WriteAnchoredTime(LastIntGain); + writer.WriteAnchoredTime(LastDexGain); byte hairflag = 0x00; @@ -6150,6 +6150,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro switch (version) { + case 38: // Stat-gain stamps moved from delta time to anchored time case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object) case 36: // Moved virtues to VirtueSystem case 35: // Moved short term murders to PlayerMurderSystem @@ -6158,9 +6159,18 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro case 32: // Removed StuckMenu case 31: { - LastStrGain = reader.ReadDeltaTime(); - LastIntGain = reader.ReadDeltaTime(); - LastDexGain = reader.ReadDeltaTime(); + if (version >= 38) + { + LastStrGain = reader.ReadAnchoredTime(); + LastIntGain = reader.ReadAnchoredTime(); + LastDexGain = reader.ReadAnchoredTime(); + } + else + { + LastStrGain = reader.ReadDeltaTime(); + LastIntGain = reader.ReadDeltaTime(); + LastDexGain = reader.ReadDeltaTime(); + } goto case 30; } diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index 68fd971ba..ab53e2f2f 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter } [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] public void WriteDeltaTime(DateTime value) { if (value == DateTime.MinValue) diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index f27b22740..c7323329f 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -504,6 +504,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); var shift = Core.Now - anchor; _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; + + // The whole save shares one anchor. Publish it so payloads without their own + // (GenericPersistence bins) can shift too; indexes load before any of them. + World.LoadTimeShift = _anchoredTimeShift; } if (version >= 4) diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 5b8e79c29..2268a86c6 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + // These payloads carry no anchor of their own; they inherit the save-wide + // shift stamped while the entity indexes were read (indexes always load + // before persistence payloads — see Persistence.Load). + AnchoredTimeShift = World.LoadTimeShift + }; Deserialize(dataReader); error = dataReader.Position != fileLength diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index 4821a708b..bfa302b39 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -43,6 +43,12 @@ public interface IGenericReader DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc); TimeSpan ReadTimeSpan() => new(ReadLong()); + /// + /// Decodes a legacy delta-time value. Only for reading old-version payloads (version + /// fallbacks and migration replays) — current formats store anchored time and read it + /// with . is + /// obsolete: no current-version format may write delta time. + /// DateTime ReadDeltaTime() { return ReadLong() switch diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 22579ab1a..4162655de 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -40,7 +40,10 @@ public interface IGenericWriter void Write(decimal value); void WriteEncodedInt(int value); void Write(DateTime value); + + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] void WriteDeltaTime(DateTime value); + void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 4111ef0d1..c00fc85f6 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -99,6 +99,15 @@ public static class World /// anchored timestamps can be re-based by the downtime at load. /// public static DateTime SaveStartTime { get; internal set; } + + /// + /// The anchored-time shift for the save currently being loaded: the downtime between the + /// save's start and this load. Stamped while entity indexes are read (they all carry the + /// same anchor, since the whole save shares one ) and applied + /// to every reader of that save's files — including + /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. + /// + public static TimeSpan LoadTimeShift { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index d1e14c834..9daea0ba3 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -27,16 +27,44 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public partial class ChampionSpawn : Item { + private void MigrateFrom(V10Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _activatedByProximity; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextProximityTime; @@ -96,7 +124,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _expireDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(20)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expireTime; @@ -109,7 +137,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _restartDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(23, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; diff --git a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs index a5426e7a5..88acd587e 100644 --- a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs +++ b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs @@ -3,15 +3,21 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class StarRoomGate : Moongate { + private void MigrateFrom(V1Content content) + { + _decays = content.Decays; + _decayTime = content.DecayTime; + } + private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0); [SerializableField(0)] private bool _decays; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _decayTime; diff --git a/Projects/UOContent/Engines/Ethics/Core/Player.cs b/Projects/UOContent/Engines/Ethics/Core/Player.cs index f21fe9eb9..d8aad9eb3 100644 --- a/Projects/UOContent/Engines/Ethics/Core/Player.cs +++ b/Projects/UOContent/Engines/Ethics/Core/Player.cs @@ -5,9 +5,20 @@ using Server.Mobiles; namespace Server.Ethics; [PropertyObject] -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Player : EthicsEntity { + private void MigrateFrom(V1Content content) + { + _mobile = content.Mobile; + _power = content.Power; + _history = content.History; + _steed = content.Steed; + _familiar = content.Familiar; + _shield = content.Shield; + _ethic = content.Ethic; + } + [SerializableField(0, setter: "private")] private Mobile _mobile; @@ -27,7 +38,7 @@ public partial class Player : EthicsEntity [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private Mobile _familiar; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5, setter: "private")] private DateTime _shield; diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index fbb3af7b6..53932c8f0 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -162,10 +162,15 @@ namespace Server.Items } } - [SerializationGenerator(0)] + [SerializationGenerator(1)] public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + _when = content.When; + } + + [AnchoredDateTime] [SerializableField(0)] private DateTime _when; diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs index 5d846d595..d3e39c09f 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs @@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests { base.Serialize(writer); - writer.Write(2); // version + writer.Write(3); // version writer.Write(MLQuestSystem.Contexts.Count); foreach (var context in MLQuestSystem.Contexts.Values) diff --git a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs index 2838c7736..77c3350f5 100644 --- a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs +++ b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs @@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives if (IsTimed) { writer.Write(true); - writer.WriteDeltaTime(EndTime); + writer.WriteAnchoredTime(EndTime); } else { @@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives { if (reader.ReadBool()) { - var endTime = reader.ReadDeltaTime(); + var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (objInstance != null) { diff --git a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs index 396b87dcd..4cd8d8c63 100644 --- a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs +++ b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs @@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence public override void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(1); // version + writer.WriteEncodedInt(2); // version writer.Write(_enabled); @@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence var si = _artifacts[i]; writer.Write(si.Item); - writer.WriteDeltaTime(si.NextRespawn); + writer.WriteAnchoredTime(si.NextRespawn); } } } @@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence for (var i = 0; i < length; i++) { var item = reader.ReadEntity(); - var nextRespawn = reader.ReadDeltaTime(); + var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (i < _artifacts.Length) { diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 490002f43..7524f77ed 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -5,10 +5,29 @@ using Server.Mobiles; namespace Server.Engines.Virtues; [PropertyObject] -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class VirtueContext { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + // Save-flagged values arrive as nullables; unset flags fall back to the same + // defaults the old deserialize left in place. + _lastSacrificeGain = content.LastSacrificeGain ?? default; + _lastSacrificeLoss = content.LastSacrificeLoss ?? default; + _availableResurrects = content.AvailableResurrects ?? 0; + _lastJusticeLoss = content.LastJusticeLoss ?? default; + _lastCompassionLoss = content.LastCompassionLoss ?? default; + _nextCompassionDay = content.NextCompassionDay ?? default; + _compassionGains = content.CompassionGains ?? 0; + _lastValorLoss = content.LastValorLoss ?? default; + _lastHonorUse = content.LastHonorUse ?? default; + _honorActive = content.HonorActive; + _justiceProtection = content.JusticeProtection; + _justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None; + _values = content.Values; + } + + [AnchoredDateTime] [SerializableField(0)] [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -16,7 +35,7 @@ public partial class VirtueContext private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -31,7 +50,7 @@ public partial class VirtueContext private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(3)] [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -39,7 +58,7 @@ public partial class VirtueContext private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(4)] [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -47,7 +66,7 @@ public partial class VirtueContext private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5)] [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] @@ -62,7 +81,7 @@ public partial class VirtueContext private bool ShouldSerializeCompassionGains() => _compassionGains > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(7)] [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -70,7 +89,7 @@ public partial class VirtueContext private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(8)] [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs new file mode 100644 index 000000000..bc9ce047e --- /dev/null +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs @@ -0,0 +1,179 @@ +using System; + +namespace Server.Items; + +public partial class Corpse +{ + // Decay timer and TimeOfDeath moved from delta time to anchored time + private void MigrateFrom(V18Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decay timer moved from [TimerDrift]/[DeserializeTimerField] to [DeserializeTimer] + private void MigrateFrom(V17Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) + private void MigrateFrom(V16Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Folded Murderer bool field into CorpseFlag.Murderer + private void MigrateFrom(V15Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Murderer) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Replaced int Kills snapshot with bool Murderer snapshot + private void MigrateFrom(V14Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Added corpse hair and corpse facial hair + private void MigrateFrom(V13Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + } +} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 59db3dc98..04e9806c9 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(18, false)] +[SerializationGenerator(19, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -106,7 +106,7 @@ public partial class Corpse : Container, ICarvable [SerializableField(1)] private CorpseFlag _flags; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDeath; @@ -120,31 +120,6 @@ public partial class Corpse : Container, ICarvable private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); - private void MigrateFrom(V17Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - _hairItemId = content.HairItemId; - _hairHue = content.HairHue; - _facialHairItemId = content.FacialHairItemId; - _facialHairHue = content.FacialHairHue; - - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } - } - [SerializableField(5, setter: "private")] private HashSet _looters; @@ -342,127 +317,6 @@ public partial class Corpse : Container, ICarvable DevourCorpse(); } - // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) - private void MigrateFrom(V16Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Folded Murderer bool field into CorpseFlag.Murderer - private void MigrateFrom(V15Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Murderer) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Replaced int Kills snapshot with bool Murderer snapshot - private void MigrateFrom(V14Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Added corpse hair and corpse facial hair - private void MigrateFrom(V13Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - } - [CommandProperty(AccessLevel.GameMaster)] public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime; diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json new file mode 100644 index 000000000..defcbe100 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json @@ -0,0 +1,189 @@ +{ + "version": 11, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json new file mode 100644 index 000000000..956de91db --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "type": "Server.Engines.Virtues.VirtueContext", + "properties": [ + { + "name": "LastSacrificeGain", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastSacrificeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "AvailableResurrects", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastJusticeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastCompassionLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "NextCompassionDay", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "CompassionGains", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastValorLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastHonorUse", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "HonorActive", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "JusticeProtection", + "type": "Server.Mobiles.PlayerMobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "JusticeStatus", + "type": "Server.Engines.Virtues.JusticeProtectorStatus", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Values", + "type": "int[]", + "usesSaveFlag": true, + "rule": "ArrayMigrationRule", + "ruleArguments": [ + "int", + "PrimitiveTypeMigrationRule", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json new file mode 100644 index 000000000..04f4a0199 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "type": "Server.Ethics.Player", + "properties": [ + { + "name": "Mobile", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Power", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "History", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Steed", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Familiar", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Shield", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Ethic", + "type": "Server.Ethics.Ethic", + "rule": "SerializableInterfaceMigrationRule" + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json new file mode 100644 index 000000000..2cccb267a --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json @@ -0,0 +1,137 @@ +{ + "version": 19, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json new file mode 100644 index 000000000..8424276f6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Items.PuzzleChestSolutionAndTime", + "properties": [ + { + "name": "When", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json new file mode 100644 index 000000000..0a61f7bfe --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "type": "Server.Items.StarRoomGate", + "properties": [ + { + "name": "Decays", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json new file mode 100644 index 000000000..3745c4428 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "type": "Server.Items.TransientItem", + "properties": [ + { + "name": "Expiration", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json new file mode 100644 index 000000000..6b9c9eb1c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json @@ -0,0 +1,62 @@ +{ + "version": 4, + "type": "Server.Mobiles.PlayerVendor", + "properties": [ + { + "name": "ShopName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextPayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "House", + "type": "Server.Multis.BaseHouse", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "BankAccount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HoldGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SellItems", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Mobiles.VendorItem\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Mobiles.VendorItem", + "RawSerializableMigrationRule", + "1", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json new file mode 100644 index 000000000..b42fa33eb --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "type": "Server.Mobiles.RentedVendor", + "properties": [ + { + "name": "RentalDurationId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LandlordRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenterRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenewalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json new file mode 100644 index 000000000..bd224f77f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Mobiles.Sheep", + "properties": [ + { + "name": "NextWoolTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json new file mode 100644 index 000000000..140d3dd64 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json @@ -0,0 +1,73 @@ +{ + "version": 5, + "type": "Server.Multis.BaseBoat", + "properties": [ + { + "name": "MapItem", + "type": "Server.Items.MapItem", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "NextNavPoint", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Facing", + "type": "Server.Direction", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDecay", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "PPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "SPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "TillerMan", + "type": "Server.Items.TillerMan", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Hold", + "type": "Server.Items.Hold", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Anchored", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ShipName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json new file mode 100644 index 000000000..471c645f1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Multis.BaseCamp", + "properties": [ + { + "name": "Items", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Mobiles", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json new file mode 100644 index 000000000..1f16a4d7f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Fifth.PoisonField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json new file mode 100644 index 000000000..3e91c7a4d --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "type": "Server.Spells.Fourth.FireFieldItem", + "properties": [ + { + "name": "Damage", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json new file mode 100644 index 000000000..dd202da03 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "type": "Server.Spells.Seventh.EnergyField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json new file mode 100644 index 000000000..3e834a6b1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Sixth.ParalyzeField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json new file mode 100644 index 000000000..eafcc5945 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Third.WallOfStone", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index 32d0883cc..741093110 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -5,9 +5,14 @@ using System.Runtime.CompilerServices; namespace Server.Mobiles { - [SerializationGenerator(0, false)] + [SerializationGenerator(1, false)] public partial class Sheep : BaseCreature, ICarvable { + private void MigrateFrom(V0Content content) + { + _nextWoolTime = content.NextWoolTime; + } + [Constructible] public Sheep() : base(AIType.AI_Animal, FightMode.Aggressor) { @@ -44,7 +49,7 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] - [DeltaDateTime] + [AnchoredDateTime] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextWoolTime; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index af85a195a..6c480ce18 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1843,7 +1843,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(20); // version + writer.Write(21); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1880,7 +1880,7 @@ namespace Server.Mobiles if (_summoned) { - writer.WriteDeltaTime(SummonEnd); + writer.WriteAnchoredTime(SummonEnd); } writer.Write(ControlSlots); @@ -2035,7 +2035,7 @@ namespace Server.Mobiles if (_summoned) { - SummonEnd = reader.ReadDeltaTime(); + SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); new UnsummonTimer(this, SummonEnd - Core.Now).Start(); } diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 5b878def2..9b8f5a2f3 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -22,9 +22,20 @@ public class PlayerVendorTargetAttribute : Attribute; * Next, uncomment the MigrateFrom function and change the `V3Content` type to match the serialization version * before it was bumped. Then run publish.cmd to generate the migration file. */ -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class PlayerVendor : Mobile { + private void MigrateFrom(V3Content content) + { + _shopName = content.ShopName; + _nextPayTime = content.NextPayTime; + _house = content.House; + _owner = content.Owner; + _bankAccount = content.BankAccount; + _holdGold = content.HoldGold; + _sellItems = content.SellItems; + } + private Timer _payTimer; [InvalidateProperties] @@ -32,7 +43,7 @@ public partial class PlayerVendor : Mobile [SerializedCommandProperty(AccessLevel.GameMaster)] private string _shopName; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextPayTime; diff --git a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs index 99d818826..d7d0a3ab6 100644 --- a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs @@ -46,9 +46,20 @@ public class VendorRentalDuration } } -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class RentedVendor : PlayerVendor { + private void MigrateFrom(V0Content content) + { + _rentalDurationId = content.RentalDurationId; + _rentalPrice = content.RentalPrice; + _landlordRenew = content.LandlordRenew; + _renterRenew = content.RenterRenew; + _renewalPrice = content.RenewalPrice; + _rentalGold = content.RentalGold; + _rentalExpireTime = content.RentalExpireTime; + } + private Timer _rentalExpireTimer; public RentedVendor( @@ -93,7 +104,7 @@ public partial class RentedVendor : PlayerVendor [SerializedCommandProperty(AccessLevel.GameMaster)] private int _rentalGold; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(6)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _rentalExpireTime; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs index d4d5ce4bd..8f0a1fba8 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs @@ -37,7 +37,7 @@ namespace Server.Mobiles Items = reader.ReadEntityList(); Gold = reader.ReadInt(); - ExpireTime = reader.ReadDeltaTime(); + ExpireTime = version >= 1 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (Items.Count == 0 && Gold == 0) { @@ -88,7 +88,7 @@ namespace Server.Mobiles public void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(0); // version + writer.WriteEncodedInt(1); // version writer.Write(Owner); writer.Write(VendorName); @@ -98,7 +98,7 @@ namespace Server.Mobiles writer.Write(Items); writer.Write(Gold); - writer.WriteDeltaTime(ExpireTime); + writer.WriteAnchoredTime(ExpireTime); } private class ExpireTimer : Timer diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 9b2d4cc37..ed618b91a 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -19,9 +19,24 @@ namespace Server.Multis Single } - [SerializationGenerator(4, false)] + [SerializationGenerator(5, false)] public abstract partial class BaseBoat : BaseMulti { + private void MigrateFrom(V4Content content) + { + _mapItem = content.MapItem; + _nextNavPoint = content.NextNavPoint; + _facing = content.Facing; + _timeOfDecay = content.TimeOfDecay; + _owner = content.Owner; + _pPlank = content.PPlank; + _sPlank = content.SPlank; + _tillerMan = content.TillerMan; + _hold = content.Hold; + _anchored = content.Anchored; + _shipName = content.ShipName; + } + public enum DryDockResult { Valid, @@ -137,7 +152,7 @@ namespace Server.Multis } [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] - [DeltaDateTime] + [AnchoredDateTime] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDecay; diff --git a/Projects/UOContent/Multis/Camps/BaseCamp.cs b/Projects/UOContent/Multis/Camps/BaseCamp.cs index e3d10dd0b..e60e3e46e 100644 --- a/Projects/UOContent/Multis/Camps/BaseCamp.cs +++ b/Projects/UOContent/Multis/Camps/BaseCamp.cs @@ -6,9 +6,16 @@ using Server.Mobiles; namespace Server.Multis; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseCamp : BaseMulti { + private void MigrateFrom(V1Content content) + { + _items = content.Items; + _mobiles = content.Mobiles; + _decayTime = content.DecayTime; + } + [Tidy] [SerializableField(0, setter: "private")] private List _items; @@ -17,7 +24,7 @@ public abstract partial class BaseCamp : BaseMulti [SerializableField(1, setter: "private")] private List _mobiles; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2, setter: "private")] private DateTime _decayTime; diff --git a/Projects/UOContent/Spells/Fifth/PoisonField.cs b/Projects/UOContent/Spells/Fifth/PoisonField.cs index 3c5d2f609..92a797f19 100644 --- a/Projects/UOContent/Spells/Fifth/PoisonField.cs +++ b/Projects/UOContent/Spells/Fifth/PoisonField.cs @@ -64,13 +64,19 @@ public class PoisonFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class PoisonField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Fourth/FireField.cs b/Projects/UOContent/Spells/Fourth/FireField.cs index 0b0a22129..8f20f995b 100644 --- a/Projects/UOContent/Spells/Fourth/FireField.cs +++ b/Projects/UOContent/Spells/Fourth/FireField.cs @@ -67,16 +67,23 @@ public class FireFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class FireFieldItem : Item { + private void MigrateFrom(V0Content content) + { + _damage = content.Damage; + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private int _damage; [SerializableField(1)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] private DateTime _end; private Timer _timer; diff --git a/Projects/UOContent/Spells/Seventh/EnergyField.cs b/Projects/UOContent/Spells/Seventh/EnergyField.cs index 5e3ea1149..dbfece36d 100644 --- a/Projects/UOContent/Spells/Seventh/EnergyField.cs +++ b/Projects/UOContent/Spells/Seventh/EnergyField.cs @@ -77,13 +77,19 @@ public class EnergyFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class EnergyField : Item { + private void MigrateFrom(V1Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs index 830f16dfe..301cee507 100644 --- a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs +++ b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs @@ -77,13 +77,19 @@ public class ParalyzeFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class ParalyzeField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs index 54ac52030..49894cc95 100644 --- a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs +++ b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs @@ -3,12 +3,17 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class TransientItem : Item { + private void MigrateFrom(V1Content content) + { + _expiration = content.Expiration; + } + private TimerExecutionToken _timerToken; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expiration; diff --git a/Projects/UOContent/Spells/Third/WallOfStone.cs b/Projects/UOContent/Spells/Third/WallOfStone.cs index 7cae3b848..1b1dd04d3 100644 --- a/Projects/UOContent/Spells/Third/WallOfStone.cs +++ b/Projects/UOContent/Spells/Third/WallOfStone.cs @@ -63,13 +63,19 @@ public class WallOfStoneSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class WallOfStone : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; From 8e39da2810e2d518ea005fc7f8e37f15860c8c52 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:13:00 -0700 Subject: [PATCH 10/20] fix: creatures track and chase targets reliably around corners (#2590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Fixes the long-standing reports of monsters losing track of players who run around a corner ("Is monster AI not using pathfinding? It seems to be LOS blocked by statics"). Root-cause investigation compared current behavior against RunUO line-by-line and traced the regressions through the AI overhaul era (#2232, #2246, #2379, #2401, #2461). ### Root causes and fixes 1. **Movement contract** — `MoveTo`/`ApproachTarget` returned false on every healthy mid-chase tick (true only on arrival), so MeleeAI's RunUO-inherited *"move failed and beyond RangePerception+1 → Guard"* clause — which RunUO only evaluated on genuine blockage — fired **every tick of every chase**. A mounted player trivially opens 17 tiles at a corner, the monster guards, Guard nulls the combatant, and re-acquisition is LOS-gated — unrecoverable through a wall. Movement now reports failure only on genuine failure (no step taken with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash. 2. **Last-known-position pursuit** — while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall), the creature walks to the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace, which had decayed to a single tick since #2246), and **re-engages instantly** if the same target re-enters view — bypassing the 10s reacquire throttle. 3. **`ChaseLeashRange`** — new virtual on BaseCreature (default `RangePerception * 2` = 32 tiles) replaces the inline `RangePerception * 3` (48) in Melee/Mage/Archer AI. Per-creature tunable via `[props`. 4. **Group movement demoted to a crowding refinement** — previously any uncontrolled creature with one ally within 8 tiles on the same target used greedy ring-stepping for the *entire* chase, with wall-slides counted as success, never invoking the pathfinder — the "aggroed but won't come around the corner" symptom for spawn groups. It now engages only near the target when allies actually contest the ring, and blocked/wall-slid steps escalate to the pathfinding approach primitive. 5. **Mages close distance on broken LOS** — a mage within casting range but LOS-blocked by geometry stood at the wall holding a spell target until the 60s combatant expiry (ProcessTarget short-circuits Think and its RunTo stands off at RangeFight). Geometry-blocked mages now close in until LOS returns, both pre-cast and while holding a target. Hidden targets (CanSee) and poison-cure priority unchanged. The new movement contract also stops the constant spurious `OnFailedMove` teleport rolls mid-chase. 6. **Move budget: one actual step per AI tick** — nothing advanced `NextMove` on a normal step (RunUO's `m_NextMove` budget was lost), so code paths attempting several moves in one think tick could cross multiple tiles at once — visible as "warping" when crowded creatures jockey for position. A successful step now consumes a half-step budget (floor 50ms): blocks intra-tick double moves, stays safely below the timer interval so legitimate next-tick moves are never jitter-throttled, and does not reintroduce `TransformMoveDelay` inflation. Blocked attempts consume nothing, so retry ladders (repath-and-step, the collision fan) are unaffected. `CanMoveNow` is also wraparound-safe now. ### Reference behavior RunUO requires LOS to *acquire* a target and to *land* a hit or spell — never to *continue* a chase (its MeleeAI LOS bail-out is literally commented out in stock code). Chases drop only on: target hidden, target dead/off-map, beyond `RangePerception * 3`, 60s without combat interaction, or blocked movement while far away. This PR restores those semantics while adding the last-known-position investigation on top. NPC run flags are untouched — pace is AI-timer-driven and most NPC art has no run animation. --- Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 52 ++++--- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 82 ++++++++-- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 140 +++++++++++++++++- Projects/UOContent/Mobiles/AI/MageAI.cs | 30 +++- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 7 + 7 files changed, 277 insertions(+), 38 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dd461a03f..dedd914c9 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -47,7 +47,7 @@ public class ArcherAI : BaseAI { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { this.DebugSayFormatted($"I have lost {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 08a745296..548fe0d54 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -36,10 +36,32 @@ public abstract partial class BaseAI } } - private bool UseGroupMovement(Mobile target) => + /// + /// Crowding refinement for the final approach: engages only near the target when allies + /// contest the ring, so creatures spread instead of stacking. Chasing any real distance + /// always uses the pathfinding approach primitive. + /// + private bool UseGroupMovement(Mobile target, int range) => Mobile.Combatant == target && !Mobile.Controlled - && CountNearbyAllies(target) > 0; + && Mobile.InRange(target, range + 2) + && CountCrowdingAllies(target, range) > 0; + + private int CountCrowdingAllies(Mobile target, int range) + { + var crowding = 0; + + foreach (var m in target.GetMobilesInRange(range + 1)) + { + if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc + && bc.Team == Mobile.Team) + { + crowding++; + } + } + + return crowding; + } public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) { @@ -56,6 +78,7 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { + return ai.MoveToWithCollisionAvoidance(target, run, range); } @@ -68,7 +91,15 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - return ai.DoMove(direction, true); + var res = ai.DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) + { + return true; + } + + // A blocked or wall-slid step is not progress — route around the obstacle. + return ai.ApproachTarget(target, run, range); } finally { @@ -76,21 +107,6 @@ public abstract partial class BaseAI } } - private int CountNearbyAllies(Mobile target) - { - var allies = 0; - foreach (var m in Mobile.GetMobilesInRange(8)) - { - if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc - && bc.Team == Mobile.Team) - { - allies++; - } - } - - return allies; - } - private PooledRefList GetNearbyAllies(Mobile target) { var allies = PooledRefList.Create(); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1063dd70a..06282a7cb 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * ************************************************************************/ +using System; using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; @@ -58,7 +59,15 @@ public abstract partial class BaseAI public bool CanMoveNow(out double delay) { delay = 0.0; - return Core.TickCount >= NextMove; + return Core.TickCount - NextMove >= 0; + } + + // Caps movement at one actual step per AI think tick; pacing itself is the timer's + // cadence. Half a step keeps the budget below the timer interval so a legitimate + // next-tick move is never jitter-throttled. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (int)(Mobile.CurrentSpeed * 500)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -103,6 +112,8 @@ public abstract partial class BaseAI Mobile.CurrentSpeed = Mobile.PassiveSpeed; } + ConsumeMoveBudget(); + return MoveResult.Success; } @@ -151,6 +162,7 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { + ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -341,31 +353,72 @@ public abstract partial class BaseAI if (res == MoveResult.BadState) { - return false; // not allowed to move this tick; not a stall + return true; // not allowed to move this tick (frozen/casting/throttled); not a failure } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { + ResetApproach(); - return Mobile.InRange(target, range); + return true; // healthy en-route progress } + // else: fall through; let the PathFollower route around the obstacle. } // PLANNING PATH: a persistent PathFollower, never discarded by a greedy step. if (Path == null || Path.Goal != target) { + Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl }; } + // Sample move-eligibility BEFORE the attempt: a successful step consumes the move + // budget, which would mask stall accounting and the progress signal. + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + if (Path.Follow(run, range)) { ResetApproach(); return true; } - TrackApproachProgress(target); - return false; + TrackApproachProgress(target, couldMove); + + // En-route progress is success; failure only when a move-eligible tick took no step + // (no working path), or the approach has given up. + var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove); + + return progressed; + } + + /// + /// 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, bool run) + { + if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) + { + return false; + } + + if (Path?.Goal != goal) + { + Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; + } + + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + + if (Path.Follow(run, 1)) + { + Path = null; + return false; // arrived + } + + return Mobile.Location != locBefore || !couldMove; } /// @@ -376,11 +429,11 @@ public abstract partial class BaseAI /// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick, /// so chases never give up even when the gap holds constant. /// - private void TrackApproachProgress(Mobile target) + private void TrackApproachProgress(Mobile target, bool couldMove) { - if (!CanMoveNow(out _)) + if (!couldMove) { - return; // a not-yet-due move (stun) is not a stall + return; // a tick that was never allowed to move (stun, stall) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -408,6 +461,7 @@ public abstract partial class BaseAI if (++_approachStallTicks >= ApproachGiveUpTicks) { + _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; @@ -444,7 +498,7 @@ public abstract partial class BaseAI return true; } - if (UseGroupMovement(m)) + if (UseGroupMovement(m, range)) { return MoveToWithGroup(this, m, shouldRun, range); } @@ -467,7 +521,11 @@ public abstract partial class BaseAI var direction = Mobile.GetDirectionTo(target); - if (DoMove(direction, true)) + // Wall-slide auto-turns must not count as progress, or a creature pinned on + // geometry reports success forever. + var res = DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) { return true; } @@ -476,14 +534,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMove(clockwise, true)) + if (DoMoveImpl(clockwise, true) == MoveResult.Success) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMove(counterclockwise, true)) + if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 57b6f6a25..4b872ee79 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,11 +26,25 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // Last-known-position tracking: recorded while the combatant is in LOS; drives the + // guard-time investigation and the instant re-engage. + private const int GuardGraceDuration = 10_000; + private const int LkpFreshDuration = 30_000; + private const int InvestigateDuration = 15_000; + private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; + private Mobile _lkpTarget; + private Point3D _lkpLocation; + private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private long _lkpExpireTick; + private long _guardStopTick; + private long _investigateStopTick; + private bool _investigating; + public PathFollower Path { get; protected set; } public AITimer AITimer { get; } public long NextMove { get; set; } @@ -233,6 +247,15 @@ public abstract partial class BaseAI return true; } + if (_action == ActionType.Combat) + { + UpdateLastKnownLocation(); + } + else if (_action is ActionType.Wander or ActionType.Guard) + { + TryReengageLastKnown(); + } + switch (Action) { case ActionType.Wander: @@ -323,6 +346,15 @@ public abstract partial class BaseAI { Mobile.Warmode = true; Mobile.Combatant = null; + + // Investigate a fresh last-seen position that is not already in view; the guard + // grace period begins once the investigation ends. + _investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 && + !(Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)); + _investigateStopTick = Core.TickCount + InvestigateDuration; + _guardStopTick = Core.TickCount + GuardGraceDuration; + _lkpGoal = null; } private void HandleFleeAction() @@ -453,18 +485,118 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (Mobile.Combatant == null) + if (_investigating) { - DebugSay("No threats found. Going home..."); - Action = ActionType.Wander; + if (InvestigateLastKnown()) + { + return true; + } + + _investigating = false; + _guardStopTick = Core.TickCount + GuardGraceDuration; } - DebugSay("I stopped being on guard."); + if (Core.TickCount - _guardStopTick < 0) + { + DebugSay("I am on guard."); + + if (Utility.Random(8) == 0) + { + Mobile.Direction = (Direction)Utility.Random(8); + } + + return true; + } + + DebugSay("I stopped being on guard. Going home..."); Action = ActionType.Wander; return true; } + /// + /// Records the combatant's position while it is visible and in line of sight. + /// + private void UpdateLastKnownLocation() + { + var combatant = Mobile.Combatant; + + if (combatant?.Deleted == false && combatant.Map == Mobile.Map && + Mobile.CanSee(combatant) && Mobile.InLOS(combatant)) + { + _lkpTarget = combatant; + _lkpLocation = combatant.Location; + _lkpExpireTick = Core.TickCount + LkpFreshDuration; + } + } + + /// + /// Re-engages the last-seen target when it returns to view within perception range, + /// bypassing the reacquire throttle. + /// + private bool TryReengageLastKnown() + { + var target = _lkpTarget; + + if (target == null) + { + return false; + } + + if (target.Deleted || !target.Alive || target.Map != Mobile.Map || + target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0) + { + ClearLastKnown(); + return false; + } + + if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None) + { + return false; + } + + if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) || + !Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false)) + { + return false; + } + + DebugSay("There you are!"); + Mobile.Combatant = target; + Mobile.FocusMob = null; + Action = ActionType.Combat; + return true; + } + + /// + /// Walks toward the last-seen position until it is in view, reached, timed out, or + /// unreachable. Returns false when the investigation is finished. + /// + private bool InvestigateLastKnown() + { + if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0) + { + return false; + } + + if (Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)) + { + DebugSay("They truly disappeared..."); + return false; + } + + _lkpGoal ??= _lkpLocation; + return MoveToPoint(_lkpGoal, false); + } + + private void ClearLastKnown() + { + _lkpTarget = null; + _lkpGoal = null; + _investigating = false; + } + public virtual bool DoActionFlee() { var from = Mobile.Combatant; diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index e9450db98..61be59a1f 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) + else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } @@ -695,6 +695,23 @@ public class MageAI : BaseAI } } + // Geometry (not hiding — CanSee passed above) is blocking the shot: close in until + // line of sight returns. Poisoned mages still fall through to cure. + if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c)) + { + DebugSay("I cannot see my target, moving to regain line of sight"); + + if (!MoveTo(c, false, 1)) + { + OnFailedMove(); + } + + _lastTarget = c; + _lastTargetLoc = c.Location; + + return true; + } + if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c)) { DebugSay("I used my abilities!"); @@ -1018,7 +1035,16 @@ public class MageAI : BaseAI if (toTarget != null) { - RunTo(toTarget); + // Without line of sight the stand-off is pointless — close in so the held + // target can be invoked. + if (!Mobile.InLOS(toTarget)) + { + MoveTo(toTarget, true, 1); + } + else + { + RunTo(toTarget); + } } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index aa262caee..544770069 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -82,7 +82,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 6c480ce18..d1a0c7faf 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -660,6 +660,13 @@ namespace Server.Mobiles [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. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual int ChaseLeashRange => RangePerception * 2; + [CommandProperty(AccessLevel.GameMaster)] public int RangeFight { get; set; } From e7f85d404d52e0def1fb342b3dc185894a57017d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:19:59 -0700 Subject: [PATCH 11/20] feat: Adds independent think/move clocks for creature AI to fix speed (#2591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits creature speed into two clocks so movement pace can be tuned without touching reaction time: - **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence. - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value. ### How - Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22). - `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs. - `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes. - `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes. - The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly. ### Behavior changes - **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted. - **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock. - **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep. - **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles. - **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too. - Removes the vestigial `MoveSpeedMod` (never read, written, or serialized). - With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before. ### Testing - Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding). - In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes. --- Distribution/Data/npc-speeds.json | 10 + .../Tests/Mobiles/AI/MoveSpeedTests.cs | 219 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 122 ++++++++-- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 70 +++++- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 19 +- Projects/UOContent/Mobiles/BaseCreature.cs | 159 ++++++++++++- Projects/UOContent/Mobiles/NPCSpeeds.cs | 23 ++ Projects/UOContent/Mobiles/Special/Paragon.cs | 3 + .../modernuo-content-patterns.md | 6 + dev-docs/content-patterns.md | 29 +++ 10 files changed, 620 insertions(+), 40 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs diff --git a/Distribution/Data/npc-speeds.json b/Distribution/Data/npc-speeds.json index 6e07859d5..707f61e4a 100644 --- a/Distribution/Data/npc-speeds.json +++ b/Distribution/Data/npc-speeds.json @@ -3,12 +3,16 @@ "level": "VerySlow", "active": 0.4, "passive": 0.8, + "activeMove": 0.9, + "passiveMove": 1.5, "types": [] }, { "level": "Slow", "active": 0.3, "passive": 0.6, + "activeMove": 0.6, + "passiveMove": 1.2, "types": [ "AntLion", "ArcticOgreLord", "BogThing", "Bogle", "BoneKnight", "EarthElemental", @@ -28,6 +32,8 @@ "level": "Medium", "active": 0.25, "passive": 0.5, + "activeMove": 0.45, + "passiveMove": 1.05, "types": [ "AcidElemental", "AgapiteElemental", "Alligator", "AncientLich", "Betrayer", "Bird", @@ -108,6 +114,8 @@ "level": "Fast", "active": 0.2, "passive": 0.4, + "activeMove": 0.3, + "passiveMove": 0.9, "types": [ "LordOaks", "Silvani", "AirElemental", "AncientWyrm", "Balron", "BladeSpirits", @@ -139,6 +147,8 @@ "level": "VeryFast", "active": 0.125, "passive": 0.30, + "activeMove": 0.125, + "passiveMove": 0.6, "types": [ "Barracoon", "Mephitis", "Neira", "Rikktor", "Semidar", "EnergyVortex", diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs new file mode 100644 index 000000000..7d9243a29 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching +// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail. +[Collection("Sequential UOContent Tests")] +public class MoveSpeedTests : IDisposable +{ + // Delete spawned stubs so they don't linger in the shared static World. + private readonly List _created = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class SpeedStub : BaseCreature + { + // Stands in for the npc-speeds table (unconfigured in the test fixture). + public double TableActiveMove; + public double TablePassiveMove; + + public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9; + + public SpeedStub(Serial serial) : base(serial) => Body = 0xC9; + + 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 = TableActiveMove; + passiveMoveSpeed = TablePassiveMove; + } + } + + private SpeedStub NewCreature() + { + var bc = new SpeedStub(); + _created.Add(bc); + return bc; + } + + [Fact] + public void MoveSpeeds_InheritThinkValues_ByDefault() + { + var bc = NewCreature(); + + Assert.Equal(0.3, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // SetSpeed left the creature passive; the think clock is untouched. + Assert.Equal(0.6, bc.CurrentSpeed); + Assert.Equal(0.9, bc.CurrentMoveSpeed); + + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentSpeed); + Assert.Equal(0.45, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_BespokePace_StaysFused() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // Neither think value verbatim, so both clocks run it. + bc.CurrentSpeed = 0.11; + Assert.Equal(0.11, bc.CurrentMoveSpeed); + } + + [Fact] + public void SetSpeed_ClearsMoveOverrides() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.SetSpeed(0.2, 0.4); + + Assert.Equal(0.2, bc.ActiveMoveSpeed); + Assert.Equal(0.4, bc.PassiveMoveSpeed); + } + + [Fact] + public void NonPositiveMoveSpeed_ClearsThatOverride() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.ActiveMoveSpeed = 0; + + Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again + Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched + } + + [Fact] + public void ScaleMoveSpeed_ScalesOverrides_LeavesInheritAlone() + { + var bc = NewCreature(); + bc.ActiveMoveSpeed = 0.6; // passive left inheriting + + bc.ScaleMoveSpeed(1.0 / 1.2); + + Assert.Equal(0.5, bc.ActiveMoveSpeed); + Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar + } + + [Fact] + public void Herding_DrivesMoveClock_ThinkUntouched() + { + var bc = NewCreature(); // think 0.3/0.6, passive + bc.SetMoveSpeed(0.45, 1.05); + + bc.TargetLocation = new Point2D(10, 10); + + Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding + Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05 + + bc.TargetLocation = null; + Assert.Equal(1.05, bc.CurrentMoveSpeed); + } + + [Fact] + public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues() + { + var bc = NewCreature(); + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + bc.SetMoveSpeed(0.45, 0.9); + + // 0.45 and 0.9 do not survive /1.2 then *1.2 bit-exactly. + bc.ScaleMoveSpeed(1.0 / 1.2); + bc.ScaleMoveSpeed(1.2); + Assert.NotEqual(0.45, bc.ActiveMoveSpeed); + + bc.SnapSpeedsToTable(); + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + + // A hand-tuned value is nowhere near the epsilon and must keep. + bc.SetMoveSpeed(0.7, 0.9); + bc.SnapSpeedsToTable(); + Assert.Equal(0.7, bc.ActiveMoveSpeed); + } + + [Fact] + public void Migration_MatchingThinkSpeeds_AdoptTableMoveValues() + { + var bc = NewCreature(); // think 0.3/0.6, matching its table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + } + + [Fact] + public void Migration_TunedThinkSpeeds_KeepInheriting() + { + var bc = NewCreature(); + bc.SetSpeed(0.35, 0.6); // hand-tuned: no longer matches the table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.35, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MoveSpeedOverrides_SurviveSerialization(bool overridden) + { + var bc = NewCreature(); + if (overridden) + { + bc.SetMoveSpeed(0.45, 0.9); + } + + 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 SpeedStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + // 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); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 06282a7cb..ccd1c72b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -38,7 +38,18 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - public static double BadlyHurtMoveDelay(BaseCreature bc) + // --- Move intent (see ContinueMove) ------------------------------------------------ + // Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while + // 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; + + // Inflates a step delay while badly hurt; computed from the passed base so it cannot + // compound across steps. Damage slows steps, never decisions. + public static double BadlyHurtMoveDelay(BaseCreature bc, double delay) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -46,14 +57,15 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var hits = (double)statMin / statMax; + var stat = (double)statMin / statMax; - if (hits < 0.1) { return bc.CurrentSpeed + 0.15; } - if (hits < 0.2) { return bc.CurrentSpeed + 0.1; } - if (hits < 0.3) { return bc.CurrentSpeed + 0.05; } + if (stat < 0.1) { return delay + 0.15; } + if (stat < 0.2) { return delay + 0.1; } + + return delay + 0.05; } - return bc.CurrentSpeed; + return delay; } public bool CanMoveNow(out double delay) @@ -62,12 +74,23 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Caps movement at one actual step per AI think tick; pacing itself is the timer's - // cadence. Half a step keeps the budget below the timer interval so a legitimate - // next-tick move is never jitter-throttled. + // 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() { - NextMove = Core.TickCount + Math.Max(50, (int)(Mobile.CurrentSpeed * 500)); + var stepDelay = Mobile.CurrentMoveSpeed; + + if (!(Core.AOS && IsFollowingMaster())) + { + stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + } + + NextMove += Math.Max(50, (long)(stepDelay * 1000)); + + if (Core.TickCount - NextMove > 0) + { + NextMove = Core.TickCount; + } } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -95,21 +118,18 @@ public abstract partial class BaseAI if (TryMove(d)) { + // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. if (Core.AOS && IsFollowingMaster()) { Mobile.CurrentSpeed = 0.1; } - else if (Mobile.Hits < Mobile.HitsMax * 0.3) - { - Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); - } else if (Mobile.Warmode || Mobile.Combatant != null) { - Mobile.CurrentSpeed = Mobile.ActiveSpeed; + Mobile.SetCurrentSpeedToActive(); } else { - Mobile.CurrentSpeed = Mobile.PassiveSpeed; + Mobile.SetCurrentSpeedToPassive(); } ConsumeMoveBudget(); @@ -319,12 +339,14 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { + ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); + ClearMoveIntent(); return true; } @@ -333,12 +355,15 @@ public abstract partial class BaseAI { if (target.Location == _approachGaveUpGoalLoc) { + ClearMoveIntent(); return false; } ResetApproach(); // target moved — try again fresh } + 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 // auto-turn sidestep can reduce Euclidean distance while moving in the wrong @@ -401,6 +426,7 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { + ClearMoveIntent(); return false; } @@ -409,16 +435,26 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } + RenewMoveIntent(null, goal, run, 1); + var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; if (Path.Follow(run, 1)) { Path = null; + ClearMoveIntent(); return false; // arrived } - return Mobile.Location != locBefore || !couldMove; + var progressed = Mobile.Location != locBefore || !couldMove; + + if (!progressed) + { + ClearMoveIntent(); + } + + return progressed; } /// @@ -461,10 +497,10 @@ public abstract partial class BaseAI if (++_approachStallTicks >= ApproachGiveUpTicks) { - _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; + ClearMoveIntent(); } } @@ -480,6 +516,56 @@ public abstract partial class BaseAI _approachGaveUp = false; } + 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. + _moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250; + } + + public void ClearMoveIntent() + { + _moveIntentTarget = null; + _moveIntentPoint = null; + } + + /// + /// True while a durable movement goal is live; is the tick + /// the movement budget elapses. + /// + public bool TryGetMoveWake(out long nextMove) + { + nextMove = NextMove; + + return (_moveIntentTarget != null || _moveIntentPoint != null) && + Core.TickCount - _moveIntentExpire < 0; + } + + /// + /// Advances the current pursuit/investigation by one step on a movement-clock wake; + /// no decisions run. + /// + public void ContinueMove() + { + if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0) + { + return; + } + + if (_moveIntentTarget != null) + { + ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + } + else + { + MoveToPoint(_moveIntentPoint, _moveIntentRun); + } + } + public virtual bool MoveTo(Mobile m, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 3c08d944b..fe24e5f6d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,9 +17,15 @@ using System; namespace Server.Mobiles; +/// +/// Drives an AI on two clocks: decisions at , plus +/// move-only wakes at while a pursuit is live. Each tick +/// schedules the earlier of the two deadlines. +/// public sealed class AITimer : Timer { private readonly BaseAI _owner; + private long _nextThink; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -28,14 +34,29 @@ public sealed class AITimer : Timer { _owner = owner; _owner._nextDetectHidden = Core.TickCount; + _nextThink = Core.TickCount; } public void Activate() { + _nextThink = Core.TickCount; Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); Start(); } + // A speed-up must not wait out a stale, longer think deadline. + public void OnSpeedChanged() + { + var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + + if (candidate - _nextThink < 0) + { + _nextThink = candidate; + } + + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + } + protected override void OnTick() { if (ShouldStop()) @@ -44,23 +65,52 @@ public sealed class AITimer : Timer return; } - _owner.Mobile.OnThink(); - - if (ShouldStop()) + if (Core.TickCount - _nextThink >= 0) { - Stop(); - return; + _owner.Mobile.OnThink(); + + if (ShouldStop()) + { + Stop(); + return; + } + + HandleBardEffects(); + + if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think()) + { + HandleDetectHidden(); + } + + // Cadence from the post-decision speed (decisions may flip active/passive). + _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + } + else + { + _owner.ContinueMove(); } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); - HandleBardEffects(); + ScheduleNext(); + } - if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) + private void ScheduleNext() + { + var now = Core.TickCount; + var delay = _nextThink - now; + + if (_owner.TryGetMoveWake(out var nextMove)) { - return; + var moveDelay = nextMove - now; + + // Only a future budget is a wake — a blocked creature must not spin the timer. + if (moveDelay > 0 && moveDelay < delay) + { + delay = moveDelay; + } } - HandleDetectHidden(); + // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. + Interval = TimeSpan.FromMilliseconds(delay); } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b872ee79..cf64cae17 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -40,6 +40,7 @@ public abstract partial class BaseAI private Mobile _lkpTarget; private Point3D _lkpLocation; private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower private long _lkpExpireTick; private long _guardStopTick; private long _investigateStopTick; @@ -58,6 +59,7 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; + NextMove = Core.TickCount; AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) @@ -295,6 +297,9 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { + // A change of course invalidates between-think movement continuation. + ClearMoveIntent(); + switch (Action) { case ActionType.Wander: @@ -623,6 +628,7 @@ public abstract partial class BaseAI if (target == null) { + _herdGoal = null; return false; } @@ -630,7 +636,15 @@ public abstract partial class BaseAI if (distance >= 1 && distance <= 15) { - DoMove(Mobile.GetDirectionTo(target)); + // A cached boxed goal keeps the PathFollower persistent across ticks; walking + // through MoveToPoint paces herding on the movement clock and paths around + // obstacles. + if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y) + { + _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); + } + + MoveToPoint(_herdGoal, false); return true; } @@ -640,6 +654,7 @@ public abstract partial class BaseAI } Mobile.TargetLocation = null; + _herdGoal = null; return false; } @@ -1131,6 +1146,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); + AITimer.OnSpeedChanged(); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index d1a0c7faf..f64a289f4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,8 +265,12 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; - // Herding - Overrides the AI to force the mob to move to a specific location - // Thinking: 0.3s, Movement: 0.6s. + // Movement clock (seconds per step); 0 = inherit the matching think value. + private double _activeMoveSpeed; + private double _passiveMoveSpeed; + + // 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; @@ -342,6 +346,7 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -673,6 +678,7 @@ namespace Server.Mobiles [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 { @@ -686,6 +692,7 @@ namespace Server.Mobiles } } + /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -700,21 +707,37 @@ namespace Server.Mobiles } } + /// 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; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { get => _targetLocation; - set - { - _targetLocation = value; - AIObject?.OnCurrentSpeedChanged(); - } + set => _targetLocation = value; } [CommandProperty(AccessLevel.GameMaster)] public double CurrentSpeed { - get => _targetLocation != null ? 0.3 : _currentSpeed; + get => _currentSpeed; set { if (Math.Abs(_currentSpeed - value) > 0.0001) @@ -725,8 +748,26 @@ namespace Server.Mobiles } } + /// + /// Resolved seconds per step: a verbatim active/passive + /// 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 MoveSpeedMod { get; set; } + public double CurrentMoveSpeed + { + get + { + if (_targetLocation != null) + { + return HerdingMoveSpeed; + } + + return _currentSpeed == _activeSpeed ? ActiveMoveSpeed + : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed + : _currentSpeed; + } + } [CommandProperty(AccessLevel.GameMaster)] public Point3D Home @@ -1850,7 +1891,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(21); // version + writer.Write(22); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1970,6 +2011,10 @@ namespace Server.Mobiles // 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) @@ -2173,6 +2218,16 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } + if (version >= 22) + { + _activeMoveSpeed = reader.ReadDouble(); + _passiveMoveSpeed = reader.ReadDouble(); + } + else + { + MigrateMoveSpeeds(); + } + if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -4586,13 +4641,78 @@ namespace Server.Mobiles return false; } + /// + /// Sets the think clock and clears movement overrides (legacy one-clock semantics); + /// use for an independent movement pace. + /// public void SetSpeed(double active, double passive, bool isPassive = true) { ActiveSpeed = active; PassiveSpeed = passive; + ClearMoveSpeed(); CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed; } + /// Sets only the movement clock (seconds per step). + public void SetMoveSpeed(double active, double passive) + { + ActiveMoveSpeed = active; + PassiveMoveSpeed = passive; + } + + /// Clears movement overrides; steps pace off the think clock again. + public void ClearMoveSpeed() + { + _activeMoveSpeed = 0; + _passiveMoveSpeed = 0; + } + + /// + /// Scales movement overrides (paragon and similar buffs). Inheriting values stay + /// inheriting — they already follow the scaled think clock. + /// + public void ScaleMoveSpeed(double scalar) + { + if (_activeMoveSpeed > 0) + { + _activeMoveSpeed *= scalar; + } + + if (_passiveMoveSpeed > 0) + { + _passiveMoveSpeed *= scalar; + } + } + + /// + /// Snaps speeds within rounding distance of the creature's table values back to + /// exact. A scaling buff that divides then multiplies can drift by an ulp (e.g. + /// 0.9 and 0.45 through 1.2), which would read as hand-tuned; call after undoing + /// such a buff. Genuinely tuned speeds are nowhere near the epsilon and keep. + /// + public void SnapSpeedsToTable() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (Math.Abs(_activeSpeed - activeSpeed) < 0.0001 && Math.Abs(_passiveSpeed - passiveSpeed) < 0.0001) + { + _activeSpeed = activeSpeed; + _passiveSpeed = passiveSpeed; + } + + GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); + + if (activeMoveSpeed > 0 && Math.Abs(_activeMoveSpeed - activeMoveSpeed) < 0.0001) + { + _activeMoveSpeed = activeMoveSpeed; + } + + if (passiveMoveSpeed > 0 && Math.Abs(_passiveMoveSpeed - passiveMoveSpeed) < 0.0001) + { + _passiveMoveSpeed = passiveMoveSpeed; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed; @@ -4906,6 +5026,25 @@ namespace Server.Mobiles NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); } + public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + } + + // 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. + internal void MigrateMoveSpeeds() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (_activeSpeed == activeSpeed && _passiveSpeed == passiveSpeed) + { + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + } + } + public virtual void DropBackpack() { var backpack = Backpack; diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index a61ce70c6..8f5e908bb 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,6 +38,22 @@ public static class NPCSpeeds passiveSpeed = sp.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. + public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) + { + if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && + !_speedsByType.TryGetValue(bc.GetType(), out sp) && + !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) + { + activeMoveSpeed = 0; + passiveMoveSpeed = 0; + return; + } + + activeMoveSpeed = sp.ActiveMoveSpeed; + passiveMoveSpeed = sp.PassiveMoveSpeed; + } + public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry; @@ -78,6 +94,13 @@ public static class NPCSpeeds [JsonPropertyName("passive")] public double PassiveSpeed { get; init; } + // Movement clock (seconds per step); absent/0 = inherit the matching think value. + [JsonPropertyName("activeMove")] + public double ActiveMoveSpeed { get; init; } + + [JsonPropertyName("passiveMove")] + public double PassiveMoveSpeed { get; init; } + [JsonPropertyName("types")] public HashSet Types { get; init; } } diff --git a/Projects/UOContent/Mobiles/Special/Paragon.cs b/Projects/UOContent/Mobiles/Special/Paragon.cs index 825609e22..cbd0a30c7 100644 --- a/Projects/UOContent/Mobiles/Special/Paragon.cs +++ b/Projects/UOContent/Mobiles/Special/Paragon.cs @@ -79,6 +79,7 @@ public static class Paragon bc.PassiveSpeed /= SpeedBuff; bc.ActiveSpeed /= SpeedBuff; + bc.ScaleMoveSpeed(1.0 / SpeedBuff); bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin += DamageBuff; @@ -143,6 +144,8 @@ public static class Paragon bc.PassiveSpeed *= SpeedBuff; bc.ActiveSpeed *= SpeedBuff; + bc.ScaleMoveSpeed(SpeedBuff); + bc.SnapSpeedsToTable(); // an ulp of scaling drift must not read as hand-tuned bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin -= DamageBuff; diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 12c9515ef..ce7f18acf 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,6 +23,12 @@ description: > 4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`** 5. **No LINQ** in game logic -- use loops and `PooledRefList` 6. **File placement** matters -- follow the directory conventions below +7. **Creature speeds are delays in seconds, on two clocks** -- think + (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move + (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until + overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think + AND clears move overrides, `SetMoveSpeed()` sets move only -- see + `dev-docs/content-patterns.md` § Creature Speeds ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 30bb942f3..ec56e1bb3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,6 +254,35 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` +### Creature Speeds (think vs move clocks) + +All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: + +- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision + (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. + +Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type +lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: + +```csharp +public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json +``` + +Code-level overrides for special cases: + +```csharp +SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics) +SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted +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). + --- ## New Spell From 38c74a968b5935a4a44c5e8265696add1009f520 Mon Sep 17 00:00:00 2001 From: Tald0r <47738492+Tald0r@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:51:42 +0200 Subject: [PATCH 12/20] fix(regions): correct end Z coordinate assignment in InitRectangles (#2597) The `ez` variable was incorrectly assigned `rect.End.X` instead of `rect.End.Z`, causing incorrect rectangle processing in region initialization. --- Projects/UOContent/Regions/BaseRegion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index b18c7fa18..776ec269d 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.X; + var ez = rect.End.Z; if (l1 < l2) { From 4420872b22bd9301225335a472ab485941898820 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:39:29 -0700 Subject: [PATCH 13/20] fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2593. Closes #2595. Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues. ## #2593 — pets follow slowly; stale AITimer wakes **Why pets slowed:** - The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**. - Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step. - The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow. **What changed:** - **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant. - **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers. The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation. ## #2595 — Guard order converts to Attack during combat **Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish. **What changed:** - **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight. - **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message. - **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active. - **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds. ## Resulting behavior (Medium-bucket pet) | Scenario | Broken | This PR | |---|---|---| | Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes | | Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master | | `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over | | Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) | | Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn | | Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) | | Wild creature chase | RunUO-parity move table | unchanged | Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract). ## Testing - Full suite passes (1570: 837 Server + 733 UOContent). - `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly. - `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain. - `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS. - All behavioral tests were written first and failed for the documented reasons. --- .../Tests/Mobiles/AI/GuardFollowTests.cs | 96 ++++++++ .../Tests/Mobiles/AI/GuardOrderTests.cs | 137 +++++++++++ .../Tests/Mobiles/AI/PetPacingTests.cs | 222 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 48 ++-- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 70 +++++- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 2 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 24 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 117 +++++---- Projects/UOContent/Mobiles/BaseCreature.cs | 7 +- .../modernuo-content-patterns.md | 7 + dev-docs/content-patterns.md | 55 +++++ 11 files changed, 712 insertions(+), 73 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs new file mode 100644 index 000000000..db5225359 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Guard-following may pathfind, so this shares the pathfinding collection. +[Collection("Sequential Pathfinding Tests")] +public class GuardFollowTests +{ + [Fact] + public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); // the order change may restart the timer + + var start = pet.Location; + ai.NextMove = 0; + ai.Obey(); + + var moved = pet.Location != start; + var hasIntent = ai.TryGetMoveWake(out _); + var currentSpeed = pet.CurrentSpeed; + var currentMoveSpeed = pet.CurrentMoveSpeed; + + pet.Delete(); + master.Delete(); + + Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + // Without a move intent, guard-following only steps on the think grid. + Assert.True(hasIntent, "guard-following must register a move intent"); + + // AOS return sprint on both clocks; the per-step speed flip must not undo it. + Assert.Equal(0.1, currentSpeed); + Assert.Equal(0.1, currentMoveSpeed); + } + + [Fact] + public void GuardReturn_PreAOS_RunsActive() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist + + ai.NextMove = 0; + ai.Obey(); + + var currentSpeed = pet.CurrentSpeed; + + pet.Delete(); + master.Delete(); + + // No sprint pre-AOS: the return runs active. + Assert.Equal(0.2, currentSpeed); + } + finally + { + Core.Expansion = previous; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs new file mode 100644 index 000000000..7d572786a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// A guarding pet fights without leaving the Guard order, retargets toward the master's +// closest aggressor, and stands down when nothing threatens. Scene: the open +// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs. +[Collection("Sequential UOContent Tests")] +public class GuardOrderTests : IDisposable +{ + private readonly List _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/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs new file mode 100644 index 000000000..217049b3d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing. +[Collection("Sequential UOContent Tests")] +public class PetPacingTests : IDisposable +{ + private readonly List _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/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index ccd1c72b6..5095a4cb4 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -118,18 +118,17 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. - if (Core.AOS && IsFollowingMaster()) + // Obeying pets are paced by their order handlers. + if (!IsObeyingMoveOrder()) { - Mobile.CurrentSpeed = 0.1; - } - else if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); + if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); + } } ConsumeMoveBudget(); @@ -541,8 +540,7 @@ public abstract partial class BaseAI { nextMove = NextMove; - return (_moveIntentTarget != null || _moveIntentPoint != null) && - Core.TickCount - _moveIntentExpire < 0; + return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; } /// @@ -574,9 +572,9 @@ public abstract partial class BaseAI } var distance = (int)Mobile.GetDistanceToSqrt(m); - var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5; - - var shouldRun = run && distance > distanceThreshold; + //TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter + var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3; + var shouldRun = distance > distanceThreshold; if (Mobile.InRange(m, range)) { @@ -599,6 +597,13 @@ 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, bool run, int range) { var distance = (int)Mobile.GetDistanceToSqrt(target); @@ -649,14 +654,12 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); - var shouldRun = run && iCurrDist > 5; - if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) { return false; } @@ -667,18 +670,17 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } + // run only sets the client animation; callers gate it on their own distance thresholds. private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) { - var shouldRun = run && iCurrDist > 5; - if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, shouldRun, iWantDistMax); + return ApproachTarget(m, run, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) + if (DoMove(m.GetDirectionTo(Mobile, run), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index fe24e5f6d..d5a84f94d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -26,6 +26,8 @@ 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; @@ -40,8 +42,30 @@ public sealed class AITimer : Timer public void Activate() { _nextThink = Core.TickCount; - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + + if (Running) + { + return; + } + + Start(); // keeps the stagger Delay + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; + } + + // Think now. A think grants no action: steps, swings, casts, and abilities keep their own gates. + public void Prod() + { + _nextThink = Core.TickCount; + + if (Running) + { + Reschedule(); + return; + } + + Delay = TimeSpan.Zero; Start(); + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } // A speed-up must not wait out a stale, longer think deadline. @@ -52,12 +76,53 @@ public sealed class AITimer : Timer if (candidate - _nextThink < 0) { _nextThink = candidate; + Reschedule(); + } + } + + // Moves the pending wake earlier. Interval is only read after the next fire, + // so this needs Stop, Delay = remaining, Start. + private void Reschedule() + { + if (_inTick || !Running) + { + return; // ScheduleNext handles it at tick end } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + var now = Core.TickCount; + var deadline = _nextThink; + + if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) + { + deadline = nextMove; + } + + if (deadline - _nextWake >= 0) + { + return; // pending wake is already early enough + } + + Stop(); + Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); + Start(); + _nextWake = now + (long)Delay.TotalMilliseconds; } protected override void OnTick() + { + _inTick = true; + + try + { + OnTickCore(); + } + finally + { + _inTick = false; + } + } + + private void OnTickCore() { if (ShouldStop()) { @@ -111,6 +176,7 @@ public sealed class AITimer : Timer // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. Interval = TimeSpan.FromMilliseconds(delay); + _nextWake = now + (long)Interval.TotalMilliseconds; } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index cf64cae17..f86ac2bfe 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.Start(); + AITimer.Activate(); } if (Action != ActionType.Wander) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index ecb456d2e..862c78649 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; } - Activate(); + AITimer.Prod(); switch (Mobile.ControlOrder) { @@ -36,6 +36,10 @@ public abstract partial class BaseAI break; } case OrderType.Come: + { + Mobile.SetCurrentSpeedToActive(); + break; + } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -135,6 +139,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); } private void HandleTransferOrder() @@ -148,6 +153,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -162,9 +168,16 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. + Mobile.SetCurrentSpeedToActive(); + + // Resuming the persistent order must not replay the flourish. + if (!_resolvingOrder) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + _commandIssuer = null; } @@ -191,6 +204,7 @@ public abstract partial class BaseAI } Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -206,6 +220,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -221,6 +236,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1b7139a96..54dd5774f 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -128,6 +128,12 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); + // AOS: sprint after the master (bespoke 0.1 paces both clocks). + if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) + { + Mobile.CurrentSpeed = 0.1; + } + if (currentDistance > 1) { WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); @@ -291,14 +297,13 @@ public abstract partial class BaseAI return true; } - FindCombatant(); + var combatant = FindGuardTarget(); - if (IsValidCombatant(Mobile.Combatant)) + if (combatant != null) { - var combatant = Mobile.Combatant; - this.DebugSayFormatted($"Attacking target: {combatant.Name}"); + // Engage without leaving the Guard order so tags, recall handling, and retargeting persist. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -309,16 +314,30 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - var guardLocation = controlMaster.Location; + // Stand down; a stale Warmode would skew the return pace. + Mobile.FocusMob = null; + Mobile.Warmode = false; + Mobile.Combatant = null; - var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); + var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); if (distance > 3) { - DoMove(Mobile.GetDirectionTo(guardLocation)); + // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. + if (Core.AOS) + { + Mobile.CurrentSpeed = 0.1; + } + else + { + Mobile.SetCurrentSpeedToActive(); + } + + WalkMobileRange(controlMaster, 1, true, 1, 3); } else { + Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -359,67 +378,83 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) + // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. + if (Mobile.ControlOrder == OrderType.Guard || + Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) { - FindCombatant(); + return; + } + + var next = FindGuardTarget(); + + if (next != null) + { + Mobile.ControlTarget = next; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = next; + + this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); + + Think(); } } - private void FindCombatant() + /// + /// 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() { var controlMaster = Mobile.ControlMaster; + var anchor = controlMaster ?? Mobile; + + var current = Mobile.Combatant; + var best = current != controlMaster && IsValidCombatant(current) ? current : null; + var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) + if (aggr == best || aggr == Mobile || aggr == controlMaster || + aggr.IsDeadBondedPet || !aggr.Alive || + aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) { continue; } - var isAttackingPet = aggr.Combatant == Mobile; - var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; + var dist = aggr.GetDistanceToSqrt(anchor); - if (isAttackingPet || isAttackingMaster) + if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) { - if (Mobile.InLOS(aggr)) - { - Mobile.ControlTarget = aggr; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggr; - - var target = isAttackingMaster ? "master" : "me"; - this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); - - Think(); - return; - } + best = aggr; + bestDist = dist; } } - if (controlMaster?.Aggressors != null) - { - for (var i = 0; i < controlMaster.Aggressors.Count; i++) - { - var aggressor = controlMaster.Aggressors[i].Attacker; + var aggressors = controlMaster?.Aggressors; - if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) + if (aggressors != null) + { + for (var i = 0; i < aggressors.Count; i++) + { + var aggressor = aggressors[i].Attacker; + + if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || + aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) { continue; } - if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + var dist = aggressor.GetDistanceToSqrt(anchor); + + if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - Mobile.ControlTarget = aggressor; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggressor; - - this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); - - Think(); - return; + best = aggressor; + bestDist = dist; } } } + + return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f64a289f4..15d9b4c03 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -750,8 +750,9 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace stays fused to both clocks. - /// A herded creature is always driven at . + /// 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 + /// . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -845,6 +846,8 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public Point3D ControlDest { get; set; } + // Fires on every assignment, not only changes: a reissued order is a command + // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index ce7f18acf..cdb3f9e49 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,6 +29,13 @@ description: > overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think AND clears move overrides, `SetMoveSpeed()` sets move only -- see `dev-docs/content-patterns.md` § Creature Speeds +8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the + think cadence (player commands prod it; speed-ups reschedule it). Gate consequential + work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call + random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the + trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a + zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see + `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index ec56e1bb3..80d925580 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,61 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). +### OnThink: the excess-call contract + +`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the +think cadence (`CurrentSpeed`), but it can and does fire more often: a player command +wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and +players run command macros that drive extra thinks deliberately (order spam is spam-safe +by design — reaction, never action). RunUO had the same property (its timer restarted +with a random delay on every speed change), so this has never been a fixed-rate callback. + +**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant +an extra action: + +- Gate consequential work on its own deadline field, compared in subtraction form + (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. +- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — + its frequency then scales with think rate, which players can influence. Per-call rolls + are acceptable only for pure cosmetics (idle animations, flavor sounds). +- The engine already gates the expensive things: steps (the `NextMove` budget), weapon + swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, + rummage, aura) all carry their own clocks. Follow that pattern. + +```csharp +private long _nextSpecial; + +public override void OnThink() +{ + base.OnThink(); + + if (Core.TickCount - _nextSpecial >= 0) + { + DoSpecial(); + _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here + } +} +``` + +### MonsterAbility: same contract + +`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and +`CombatAction`-triggered abilities, so abilities live under the same rule: + +- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds + no matter how often thinks fire. Always give a triggered ability a real cooldown. +- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected + trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as + the rate limiter, and keep cooldowns long relative to the think interval so the jitter + stays negligible (fire breath — chance 0.5, cooldown 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 From e07416902afebad3affbbdc1fbbf8c4a097df4d9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:48:52 -0700 Subject: [PATCH 14/20] feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces. ### Why The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk. The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal. ### What **Pace-derived run flag** - `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles. - `DoMoveImpl` stamps the bit; it is the single place the flag is set. - `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds. **Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces) - A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport. - Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift. - Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich). - Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests). ### Accepted trade-off Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930. ### Tests `RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green. --- .../Fixtures/TestServerInitializer.cs | 1 + .../Tests/Mobiles/AI/ApproachTargetTests.cs | 10 +- .../Tests/Mobiles/AI/RunFlagTests.cs | 162 ++++++++++++++++++ .../Factions/Mobiles/Guards/GuardAI.cs | 4 +- .../UOContent/Engines/Pathing/PathFollower.cs | 6 +- Projects/UOContent/Mobiles/AI/AnimalAI.cs | 2 +- Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 6 +- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 94 +++++----- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 8 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 6 +- Projects/UOContent/Mobiles/AI/BerserkAI.cs | 2 +- Projects/UOContent/Mobiles/AI/HealerAI.cs | 2 +- Projects/UOContent/Mobiles/AI/MageAI.cs | 10 +- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 2 +- Projects/UOContent/Mobiles/AI/PredatorAI.cs | 4 +- Projects/UOContent/Mobiles/AI/ThiefAI.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 2 +- .../Mobiles/Familiars/BaseFamiliar.cs | 2 +- .../Monsters/LBR/Meers/EnragedCreatures.cs | 2 +- .../UOContent/Spells/Ninjitsu/MirrorImage.cs | 5 +- .../migrate-items-mobiles.md | 1 + .../modernuo-content-patterns.md | 5 +- dev-docs/content-patterns.md | 12 ++ .../09-items-mobiles-creatures.md | 23 +++ .../runuo-migration-docs/11-api-reference.md | 3 + 26 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index a38af9c3d..5e4230b7c 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -103,6 +103,7 @@ internal static class TestServerInitializer // Registers the Accounts entity persistence; without it no test can construct an Account. Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); + Server.Movement.Movement.Configure(); MovementImpl.Configure(); PathFollower.Configure(); World.Load(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index 923f4aff9..a952252a6 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, false, 1, 2); + ai.WalkMobileRange(target, 1, 1, 2); if (bc.InRange(target, arriveDist)) { return true; @@ -123,7 +123,7 @@ public class ApproachTargetTests for (var i = 0; i < 200; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.InRange(target, 1)) { arrived = true; @@ -154,7 +154,7 @@ public class ApproachTargetTests for (var i = 0; i < 60; i++) { ai.NextMove = 0; - ai.MoveTo(target, true, 1); + ai.MoveTo(target, 1); // Target walks west every other tick for its first several steps, then stops, // so a same-speed chaser eventually closes the gap. @@ -214,7 +214,7 @@ public class ApproachTargetTests for (var i = 0; i < 120; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); } // After giving up, the creature must idle (not oscillate) while the goal is still. @@ -223,7 +223,7 @@ public class ApproachTargetTests for (var i = 0; i < 20; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.Location != idleStart) { stayedIdle = false; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs new file mode 100644 index 000000000..a421aaba2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// The Running bit is derived from the step pace: a step shorter than the client's walk +// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run. +[Collection("Sequential Pathfinding Tests")] +public class RunFlagTests : System.IDisposable +{ + private readonly List _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/Engines/Factions/Mobiles/Guards/GuardAI.cs b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs index 32e8566cb..f0fb2386f 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, true, 1 )) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, true, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 3a0a56fa5..04aaf0148 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(bool run, int range) + public bool Follow(int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal, run); + d = m_From.GetDirectionTo(goal); m_From.SetDirection(d); return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - d = m_From.GetDirectionTo(m_Next, run); + d = m_From.GetDirectionTo(m_Next); m_From.SetDirection(d); var res = Move(d); diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index a9d410483..e5a1670e3 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, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, 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 dedd914c9..23ce0549c 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -43,7 +43,7 @@ public class ArcherAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 548fe0d54..4463bf4de 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, bool run, int range) + public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range) { if (Core.TickCount - _lastGroupUpdateTime > 1000) { @@ -79,7 +79,7 @@ public abstract partial class BaseAI if (optimalPosition == Point3D.Zero) { - return ai.MoveToWithCollisionAvoidance(target, run, range); + return ai.MoveToWithCollisionAvoidance(target, range); } _reservedPositions[mobile] = optimalPosition; @@ -99,7 +99,7 @@ public abstract partial class BaseAI } // A blocked or wall-slid step is not progress — route around the obstacle. - return ai.ApproachTarget(target, run, range); + return ai.ApproachTarget(target, range); } finally { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 5095a4cb4..8b2a16cf2 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -18,6 +18,7 @@ using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; using MoveImpl = Server.Movement.MovementImpl; +using Moves = Server.Movement.Movement; namespace Server.Mobiles; @@ -43,7 +44,6 @@ public abstract partial class BaseAI // live, the AITimer wakes at NextMove between think ticks to advance the step. private Mobile _moveIntentTarget; private IPoint3D _moveIntentPoint; - private bool _moveIntentRun; private int _moveIntentRange; private long _moveIntentExpire; @@ -74,23 +74,42 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly - // regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step. - private void ConsumeMoveBudget() + // Seconds per step as the client observes it: the move clock plus the hurt inflation. + private double EffectiveStepDelay() { var stepDelay = Mobile.CurrentMoveSpeed; - if (!(Core.AOS && IsFollowingMaster())) + return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay); + } + + // The Running bit only selects the client's per-step interpolation (walk 400ms / run + // 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the + // client falls behind and snaps — but an isolated step (after standing at least a walk + // interval) renders alone and darts if run-flagged, so it goes out as a walk. A true + // sprinter always runs: a walk-rendered first step would flood the client's queue. + public bool ShouldRun() + { + var mounted = Mobile.Mounted || Mobile.Flying; + var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay; + var pace = EffectiveStepDelay() * 1000; + + if (pace >= walkDelay) { - stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + return false; } - NextMove += Math.Max(50, (long)(stepDelay * 1000)); + var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay; - if (Core.TickCount - NextMove > 0) - { - NextMove = Core.TickCount; - } + return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay; + } + + // One step per period, paced from the step just taken — no debt accrual: repaying a + // late step with a quicker follow-up puts two steps ~100ms apart, which renders as a + // dart. In continuous pursuit the move-wake lands within wheel resolution of this + // deadline, so the only cost is single-digit-ms drift per step. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -108,6 +127,8 @@ public abstract partial class BaseAI return MoveResult.BadState; } + d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0); + if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask)) { Mobile.Direction = d; @@ -334,7 +355,7 @@ public abstract partial class BaseAI /// best-distance stall counter idles the creature if an in-range goal is genuinely /// unreachable, without ever abandoning a real chase or detour. /// - protected bool ApproachTarget(Mobile target, bool run, int range) + protected bool ApproachTarget(Mobile target, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { @@ -361,7 +382,7 @@ public abstract partial class BaseAI ResetApproach(); // target moved — try again fresh } - RenewMoveIntent(target, null, run, range); + RenewMoveIntent(target, null, range); // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An @@ -373,7 +394,7 @@ public abstract partial class BaseAI if (Path == null && Mobile.InLOS(target)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); if (res == MoveResult.BadState) { @@ -402,7 +423,7 @@ public abstract partial class BaseAI var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(run, range)) + if (Path.Follow(range)) { ResetApproach(); return true; @@ -421,7 +442,7 @@ public abstract partial class BaseAI /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around /// obstacles. Returns false on arrival or when genuinely unable to make progress. /// - public bool MoveToPoint(IPoint3D goal, bool run) + public bool MoveToPoint(IPoint3D goal) { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { @@ -434,12 +455,12 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } - RenewMoveIntent(null, goal, run, 1); + RenewMoveIntent(null, goal, 1); var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(run, 1)) + if (Path.Follow(1)) { Path = null; ClearMoveIntent(); @@ -515,11 +536,10 @@ public abstract partial class BaseAI _approachGaveUp = false; } - private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) + private void RenewMoveIntent(Mobile target, IPoint3D point, int range) { _moveIntentTarget = target; _moveIntentPoint = point; - _moveIntentRun = run; _moveIntentRange = range; // A live pursuit renews every think tick; unrenewed intent dies on its own. @@ -556,26 +576,21 @@ public abstract partial class BaseAI if (_moveIntentTarget != null) { - ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + ApproachTarget(_moveIntentTarget, _moveIntentRange); } else { - MoveToPoint(_moveIntentPoint, _moveIntentRun); + MoveToPoint(_moveIntentPoint); } } - public virtual bool MoveTo(Mobile m, bool run, int range) + public virtual bool MoveTo(Mobile m, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) { return false; } - var distance = (int)Mobile.GetDistanceToSqrt(m); - //TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter - var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3; - var shouldRun = distance > distanceThreshold; - if (Mobile.InRange(m, range)) { ResetApproach(); @@ -584,10 +599,10 @@ public abstract partial class BaseAI if (UseGroupMovement(m, range)) { - return MoveToWithGroup(this, m, shouldRun, range); + return MoveToWithGroup(this, m, range); } - return ApproachTarget(m, shouldRun, range); + return ApproachTarget(m, range); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -604,12 +619,8 @@ public abstract partial class BaseAI Mobile.Combatant == null && Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; - private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) + private bool MoveToWithCollisionAvoidance(Mobile target, 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 @@ -640,10 +651,10 @@ public abstract partial class BaseAI // Tactical sidesteps exhausted — route around the obstacle via the centralized // approach primitive (persistent PathFollower, no oscillation). - return ApproachTarget(target, shouldRun, range); + return ApproachTarget(target, range); } - public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax) + public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null) { @@ -659,7 +670,7 @@ public abstract partial class BaseAI return true; } - if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax)) { return false; } @@ -670,17 +681,16 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - // run only sets the client animation; callers gate it on their own distance thresholds. - private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) + private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax) { if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, run, iWantDistMax); + return ApproachTarget(m, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, run), true)) + if (DoMove(m.GetDirectionTo(Mobile), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index f86ac2bfe..3c2a479e0 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -442,7 +442,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, false, 1); + MoveTo(master, 1); } } @@ -592,7 +592,7 @@ public abstract partial class BaseAI } _lkpGoal ??= _lkpLocation; - return MoveToPoint(_lkpGoal, false); + return MoveToPoint(_lkpGoal); } private void ClearLastKnown() @@ -644,7 +644,7 @@ public abstract partial class BaseAI _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); } - MoveToPoint(_herdGoal, false); + MoveToPoint(_herdGoal); return true; } @@ -798,7 +798,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 54dd5774f..e6c48850d 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, false, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, 1, 2); if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2) { @@ -136,7 +136,7 @@ public abstract partial class BaseAI if (currentDistance > 1) { - WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); } } @@ -333,7 +333,7 @@ public abstract partial class BaseAI Mobile.SetCurrentSpeedToActive(); } - WalkMobileRange(controlMaster, 1, true, 1, 3); + WalkMobileRange(controlMaster, 1, 1, 3); } else { diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 4663ae8d0..ff00ec91d 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index 2a1127b43..cc54c73c3 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, false, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, 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 61be59a1f..1ed8cbdda 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, false, Mobile.RangeFight)) + if (!MoveTo(m, Mobile.RangeFight)) { OnFailedMove(); } @@ -185,14 +185,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, false, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } @@ -701,7 +701,7 @@ public class MageAI : BaseAI { DebugSay("I cannot see my target, moving to regain line of sight"); - if (!MoveTo(c, false, 1)) + if (!MoveTo(c, 1)) { OnFailedMove(); } @@ -1039,7 +1039,7 @@ public class MageAI : BaseAI // target can be invoked. if (!Mobile.InLOS(toTarget)) { - MoveTo(toTarget, true, 1); + MoveTo(toTarget, 1); } else { diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index 544770069..a71d83d5c 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -99,7 +99,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, false, Mobile.RangeFight)) + if (MoveTo(combatant, Mobile.RangeFight)) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index 5e0e01520..b1ea3a638 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, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { @@ -70,7 +70,7 @@ public class PredatorAI : BaseAI } else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("Well, here I am safe"); diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index 0fa37bbc9..a9209dfbb 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, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I should be closer to {combatant.Name}"); } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 15d9b4c03..05c728cf4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2861,7 +2861,7 @@ namespace Server.Mobiles CanBeHarmful(m) && IsEnemy(m)) { Combatant = FocusMob = m; - AIObject?.MoveTo(m, true, 1); + AIObject?.MoveTo(m, 1); DoHarmful(m); } } diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index d3b119523..46d939f3b 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, false, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, 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 91367a5f0..ce4f3526d 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, false, 5); + AIObject?.MoveTo(SummonMaster, 5); } /* On OSI, if the summon attacks a mobile, the summoner meer also diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 6d85b9d73..63788aa69 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,10 +238,7 @@ namespace Server.Mobiles if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true) { - var iCurrDist = (int)Mobile.GetDistanceToSqrt(master); - var bRun = iCurrDist > 5; - - WalkMobileRange(master, 2, bRun, 0, 1); + WalkMobileRange(master, 2, 0, 1); } else { 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 8edf70a72..ce70ad26c 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,6 +29,7 @@ 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 ## 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 cdb3f9e49..2b6df7d55 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,8 +27,9 @@ description: > (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only -- see - `dev-docs/content-patterns.md` § Creature Speeds + AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is + derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- + see `dev-docs/content-patterns.md` § Creature Speeds 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 diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 80d925580..3d11efb16 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,18 @@ 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. + ### OnThink: the excess-call contract `OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the 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 c5029131c..7d4581b20 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,6 +474,29 @@ 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. + ## 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 215dd3b06..358bf13c7 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,6 +130,9 @@ 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 | ## Networking From c9875e7f642ce505a5231e8dcfd58bfc955fc31b Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:46:20 +0200 Subject: [PATCH 15/20] fix: delete the bonus item, not the primary yield, when the bonus cannot be placed (#2602) --- Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs index b82040134..b67665d04 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 { - item.Delete(); + bonusItem?.Delete(); } } From 547c2ea0fa1acfcc1914e0805f25d1b48977454a Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:25:20 -0700 Subject: [PATCH 16/20] fix: Fixes tick count wrap-around in movement throttle, and eliminates more allocations in NetState (#2603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Removes the per-tick allocation in the movement throttle, fixes tick-count wrap-around bugs in the throttle and RTT probe state, and trims per-connection allocations and dead fields in `NetState`. ## Movement throttle - **No more per-tick `List` snapshot.** `ProcessAllQueues()` iterates the `HashSet` directly and removes drained or disconnected states in place. `HashSet.Remove` does not invalidate enumerators on .NET Core 3.0+ (verified on 10.0.11); only inserting a *new* member does, and the only `Add` is in the packet handler, which never nests with `Slice()`. The eager `Remove` calls in `RejectAndReset`, `ClearQueue`, and `ProcessMovementQueue` are gone; membership is reconciled once per tick from `_hasQueuedMovements`. - **Debug logging** is now gated solely by the per-connection `NetState.MovementLogging` flag. The global `movementThrottle.debugLogging` setting is removed. - **New settings**: `movementThrottle.maxRttBonus`, `movementThrottle.maxChainGap`, and `movementThrottle.speedHackNotificationCooldown` were fields with no config binding. ## Tick-count wrap-around All comparisons are now in subtraction form and no tick field uses zero as a sentinel: - `now < _nextMovementTime` in the queue drain loop → `now - _nextMovementTime < 0`. - `_lastMovementRecordTime > 0`, `_lastSpeedHackNotification`, `_rttProbeTime > 0`, and `_nextRttProbe == 0` sentinels replaced with `_hasMovementRecord`, `_speedHackNotified`, `_rttProbePending`, and a seeded `_nextRttProbe`. - `_lastQueueDepthCheck` and `_movementWindowStart` are seeded from `Core.TickCount` at construction and on reset instead of zero. User-visible effects of the old code: on hosts with pass-through counters (GCP) movement history never recorded and speed hack detection was silently off; on every host, staff speed hack notifications were suppressed until `Core.TickCount` exceeded the five-minute cooldown. ## NetState - `Instances` returns `HashSet` again so engine-internal `foreach` uses the struct enumerator instead of boxing through `IReadOnlySet`. - Removed `_sustainedQueueDepth` (declared and zeroed since #2266, never read), `_lastRtt` (now derived as `LastRtt` from the newest history slot), and `_rttProbeTimestampHiRes` (only fed one debug log line). 20 bytes per connection. - `HuePickers`, `Menus`, and `Trades` are lazily created instead of allocating three lists per connection, including every login-server connection that dies on shard select. `Trades` is released when it empties. All helpers and the `HuePickerResponse` / `MenuResponse` handlers are null-tolerant; the trade cancel loops keep their `i < Count` guards because `SecureTrade.Cancel()` runs virtual item hooks that can re-enter the same list. ## Testing - `dotnet build -c Release` clean. - All MovementThrottle tests pass (27), plus the Trade / Menu / HuePicker / NetState tests (32). --- Projects/Server/Mobiles/Mobile.cs | 2 +- Projects/Server/Network/MovementThrottle.cs | 138 ++++++++---------- .../Network/NetState/NetState.Movement.cs | 52 +++---- Projects/Server/Network/NetState/NetState.cs | 60 +++++++- .../Network/Packets/IncomingPlayerPackets.cs | 21 ++- 5 files changed, 153 insertions(+), 120 deletions(-) diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index fb028b9d7..208142225 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -1627,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; } diff --git a/Projects/Server/Network/MovementThrottle.cs b/Projects/Server/Network/MovementThrottle.cs index c28ef2228..33ee318fc 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -50,9 +50,6 @@ public static class MovementThrottle private const int ClientMaxUnackedMovements = 5; private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 - // Debug logging - enable for testing speed hack detection - private static bool _debugLogging = false; - // Track NetStates with queued movements for efficient processing private static readonly HashSet _netStatesWithQueuedMovements = new(256); @@ -83,15 +80,9 @@ public static class MovementThrottle public static void Configure() { - _maxCredit = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.maxCredit", - _maxCredit - ); - - _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.hardQueueLimit", - _hardQueueLimit - ); + _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); + _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); + _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", @@ -103,6 +94,13 @@ public static class MovementThrottle _minSamplesForRate ); + _maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap); + + _speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.speedHackNotificationCooldown", + _speedHackNotificationCooldown + ); + _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold @@ -112,11 +110,6 @@ public static class MovementThrottle "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); - - _debugLogging = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.debugLogging", - _debugLogging - ); } /// @@ -191,15 +184,16 @@ public static class MovementThrottle // Credit can go negative up to -dynamicCredit (debt limit) if (ns._movementCredit - earlyAmount >= -dynamicCredit) { - var prevCredit = ns._movementCredit; // Use credit to cover early arrival ns._movementCredit -= earlyAmount; - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { + var prevCredit = ns._movementCredit + earlyAmount; + logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit + mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit ); } @@ -208,11 +202,11 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", - mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit + mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit ); } @@ -227,11 +221,11 @@ public static class MovementThrottle var prevCredit = ns._movementCredit; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); - if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) + if (ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit + mobile, delta, prevCredit, ns._movementCredit, dynamicCredit ); } } @@ -247,12 +241,9 @@ public static class MovementThrottle { if (!mobile.Move(dir)) { - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { - logger.Debug( - "[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", - mobile.RawName, dir, seq - ); + logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq); } // Movement failed (blocked, paralyzed, frozen, etc.) @@ -260,11 +251,11 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", - mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount + mobile, dir, seq, ns._nextMovementTime - Core.TickCount ); } @@ -304,11 +295,11 @@ public static class MovementThrottle ns._hasQueuedMovements = true; _netStatesWithQueuedMovements.Add(ns); - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", - ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count + ns.Mobile, dir, seq, ns._movementQueue.Count ); } } @@ -320,7 +311,6 @@ public static class MovementThrottle { ns.SendMovementRej(seq, mobile); ns.ResetMovementState(); - _netStatesWithQueuedMovements.Remove(ns); } /// @@ -333,20 +323,18 @@ public static class MovementThrottle return; } - // Process each NetState with queued movements - // Use a snapshot to avoid modification during iteration - var toProcess = new List(_netStatesWithQueuedMovements); - - for (var i = 0; i < toProcess.Count; i++) + foreach (var ns in _netStatesWithQueuedMovements) { - var ns = toProcess[i]; - if (!ns.Running) + if (ns.Running) { - _netStatesWithQueuedMovements.Remove(ns); - continue; + ProcessMovementQueue(ns); + if (ns._hasQueuedMovements) + { + continue; + } } - ProcessMovementQueue(ns); + _netStatesWithQueuedMovements.Remove(ns); } } @@ -356,6 +344,7 @@ public static class MovementThrottle public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; + if (mobile?.Deleted != false) { ClearQueue(ns); @@ -374,7 +363,7 @@ public static class MovementThrottle while (ns._movementQueue?.Count > 0) { // Check if it's time to execute - if (now < ns._nextMovementTime) + if (now - ns._nextMovementTime < 0) { // Not yet - leave remaining items in queue for next Slice break; @@ -394,11 +383,11 @@ public static class MovementThrottle // Execute the move if (!mobile.Move(movement.Direction)) { - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", - mobile.RawName, movement.Direction, remaining + mobile, movement.Direction, remaining ); } @@ -407,12 +396,12 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", - mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 + mobile, movement.Direction, remaining, waited >= 0 ? waited : 0 ); } @@ -430,10 +419,6 @@ public static class MovementThrottle // Update tracking ns._hasQueuedMovements = ns._movementQueue?.Count > 0; - if (!ns._hasQueuedMovements) - { - _netStatesWithQueuedMovements.Remove(ns); - } } /// @@ -469,7 +454,6 @@ public static class MovementThrottle { ns._movementQueue?.Clear(); ns._hasQueuedMovements = false; - _netStatesWithQueuedMovements.Remove(ns); } // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) @@ -484,7 +468,7 @@ public static class MovementThrottle logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", - mobile?.RawName ?? "Unknown", + mobile, ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address @@ -516,7 +500,7 @@ public static class MovementThrottle private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) { // Calculate interval since last movement - var interval = ns._lastMovementRecordTime > 0 + var interval = ns._hasMovementRecord ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) @@ -525,6 +509,7 @@ public static class MovementThrottle if (interval <= 0 || interval > _maxChainGap) { ns._lastMovementRecordTime = now; + ns._hasMovementRecord = true; // Use RTT to distinguish "stopped moving" vs "lagged" // - Stable low-latency connection with gap >> RTT → player stopped, reset history @@ -544,19 +529,19 @@ public static class MovementThrottle // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { var action = shouldReset ? "history reset" : "history preserved (possible lag)"; logger.Debug( "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "RTT={RTT}ms stable={Stable} → {Action})", - mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action + mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action ); } } - else if (_debugLogging && mobile?.RawName != null) + else if (ns._movementLogging) { - logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); + logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile); } return; @@ -572,12 +557,9 @@ public static class MovementThrottle // the next real move's interval artificially short, inflating rate. if (cost == 0) { - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { - logger.Debug( - "[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", - mobile.RawName - ); + logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile); } return; } @@ -613,15 +595,16 @@ public static class MovementThrottle } ns._lastMovementRecordTime = now; + ns._hasMovementRecord = true; // Debug logging - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; logger.Debug( "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", - mobile.RawName, interval, cost, record.QueueDepth, + mobile, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } @@ -814,7 +797,7 @@ public static class MovementThrottle var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging - if (_debugLogging) + if (ns._movementLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); @@ -977,19 +960,19 @@ public static class MovementThrottle var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging - if (_debugLogging && ns.Mobile?.RawName != null) + if (ns._movementLogging) { var (burstSize, _) = DetectRecentBurst(ns); - var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; + var probeStatus = ns._rttProbePending ? "pending" : "idle"; var queueDepth = ns._movementQueue?.Count ?? 0; logger.Debug( "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", - ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds + ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ); logger.Debug( " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", - ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus + ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ); } @@ -1025,11 +1008,11 @@ public static class MovementThrottle if (shouldNotify) { - if (_debugLogging) + if (ns._movementLogging) { logger.Debug( "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", - urgency, ns.Mobile?.RawName, rate, verdict, confidence + urgency, ns.Mobile, rate, verdict, confidence ); } NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); @@ -1054,11 +1037,12 @@ public static class MovementThrottle var now = Core.TickCount; // Rate-limit notifications per player - if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) + if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } + ns._speedHackNotified = true; ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; @@ -1070,7 +1054,7 @@ public static class MovementThrottle "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", urgency, - mobile?.RawName ?? "Unknown", + mobile, ns.Account?.Username ?? "Unknown", rate, sampleCount, @@ -1138,7 +1122,7 @@ public static class MovementThrottle Verdict = verdict, Confidence = confidence, AverageRtt = ns.AverageRtt, - LastRtt = ns._lastRtt, + LastRtt = ns.LastRtt, RttVariance = ns._rttVariance, StableConnection = ns.HasStableConnection, RttSampleCount = ns._rttSampleCount, diff --git a/Projects/Server/Network/NetState/NetState.Movement.cs b/Projects/Server/Network/NetState/NetState.Movement.cs index 1cc79429e..c6f28fcd1 100644 --- a/Projects/Server/Network/NetState/NetState.Movement.cs +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Runtime.InteropServices; using Server.Logging; @@ -70,23 +69,24 @@ public partial class NetState internal Queue _movementQueue; // Lazy initialized internal long _movementCredit; // Credit buffer for timing jitter internal long _nextMovementTime = Core.TickCount; // When next movement is allowed - internal int _sustainedQueueDepth; // Tracks sustained high queue depth - internal long _lastQueueDepthCheck; // Throttle depth check frequency + internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency internal bool _hasQueuedMovements; // Fast check for Slice() // Movement history for rate-based speed hack detection (lazy initialized) internal MovementRecord[] _movementHistory; // Circular buffer internal int _movementHistoryIndex; // Next write position (also serves as count until full) internal bool _movementHistoryFull; // True once buffer has wrapped - internal long _lastMovementRecordTime; // For calculating intervals + internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord) + internal bool _hasMovementRecord; // False until the first movement in a chain is seen // Detection state internal int _consecutiveHighRateSeconds; // Sustained detection counter - internal long _lastSpeedHackNotification; // Rate-limit notifications + internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) + internal bool _speedHackNotified; // False until the first notification is sent internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) // Movement packet rate tracking (for speed hack detection) - internal long _movementWindowStart; // Start of current 1-second window + internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window internal int _movementsInWindow; // Count in current window internal int _peakMovementRate; // Highest rate seen (packets/sec) @@ -100,10 +100,9 @@ public partial class NetState _nextMovementTime = Core.TickCount; _movementCredit = 0; _hasQueuedMovements = false; - _sustainedQueueDepth = 0; // Reset movement history - next movement starts a new chain - _lastMovementRecordTime = 0; + _hasMovementRecord = false; _movementHistoryIndex = 0; _movementHistoryFull = false; @@ -113,7 +112,7 @@ public partial class NetState _rttProbeInterval = RttProbeIntervalNormal; // Reset packet rate window - _movementWindowStart = 0; + _movementWindowStart = Core.TickCount; _movementsInWindow = 0; } @@ -165,17 +164,19 @@ public partial class NetState private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection // RTT state - internal long _rttProbeTime; // When we sent the probe (0 = not waiting) - internal long _lastRtt; // Most recent RTT measurement + internal bool _rttProbePending; // True while waiting for a probe response + internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) internal long[] _rttHistory; // Rolling history (lazy init) internal int _rttHistoryIndex; // Current position in history internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize) internal long _rttVariance; // Calculated variance for stability - internal long _nextRttProbe; // When to send next probe + internal long _nextRttProbe = Core.TickCount; // When to send next probe internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval - // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) - private long _rttProbeTimestampHiRes; + /// + /// Gets the most recent RTT measurement, or 0 if none has been recorded. + /// + public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0; /// /// Sets the RTT probe interval based on suspicion level. @@ -206,23 +207,22 @@ public partial class NetState var now = Core.TickCount; // Don't send if we're still waiting for a response - if (_rttProbeTime > 0) + if (_rttProbePending) { // Timeout after 10 seconds - connection is probably dead or very laggy if (now - _rttProbeTime > 10000) { - _rttProbeTime = 0; - _rttProbeTimestampHiRes = 0; + _rttProbePending = false; } return; } // First probe: send immediately when player starts moving // Subsequent probes: send when interval has passed - if (_nextRttProbe == 0 || now >= _nextRttProbe) + if (now - _nextRttProbe >= 0) { + _rttProbePending = true; _rttProbeTime = now; - _rttProbeTimestampHiRes = Stopwatch.GetTimestamp(); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); if (_movementLogging) @@ -242,10 +242,9 @@ public partial class NetState /// public void RecordRttMeasurement() { - var nowHiRes = Stopwatch.GetTimestamp(); var now = Core.TickCount; - if (_rttProbeTime <= 0) + if (!_rttProbePending) { // Not expecting a response (client-initiated version send) - ignore silently return; @@ -253,19 +252,15 @@ public partial class NetState var rtt = now - _rttProbeTime; - // High-resolution RTT in microseconds - var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency; - if (_movementLogging) { movementLogger.Debug( - "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)", - Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 + "[RTT-Response] {Account}: {Rtt}ms", + Account?.Username ?? _toString, rtt ); } - _rttProbeTime = 0; - _rttProbeTimestampHiRes = 0; + _rttProbePending = false; // Sanity check - RTT should be positive and reasonable if (rtt is <= 0 or > 10000) @@ -285,7 +280,6 @@ public partial class NetState // Update history _rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt; - _lastRtt = rtt; // Track sample count (saturates at buffer size) if (_rttSampleCount < RttHistorySize) diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 7312603f7..04f1a5a76 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 IReadOnlySet Instances => _instances; + public static HashSet Instances => _instances; private readonly string _toString; private ClientVersion _version; @@ -109,9 +109,6 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode Trades { get; } + public List Trades { get; private set; } public bool Seeded { get; set; } @@ -260,8 +257,18 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { + if (Trades == null) + { + break; + } + if (i >= Trades.Count) { continue; @@ -280,8 +287,18 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { + if (Trades != null) + { + break; + } + if (i < Trades.Count) { Trades[i].Cancel(); @@ -291,11 +308,21 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode Date: Tue, 1 Sep 2026 20:42:15 -0700 Subject: [PATCH 17/20] feat: event-driven target acquisition with a reaction-time gradient (#2601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes walk-up aggro latency (up to a full 10 s of obliviousness) and hardens the reacquire gate so no state can silence acquisition, while turning `AcquireOnApproach` into the reaction-time knob for future per-creature intelligence tuning. ### Why `AcquireFocusMob` re-armed the 10 s `ReacquireDelay` **before** scanning, success or failure. A creature that scanned an empty room was blind for 10 s to a player walking up — walk-up aggro latency was uniform in 0..10 s. Waking from sector sleep stacked the AI timer's 0–3 s construction stagger on top. And `NextReacquireTime` is not serialized: on hosts whose tick counter starts negative (GCP pass-through), the 0 default blocked **all** acquisition shard-wide after a restart until the counter crossed zero. ### What **Event-driven reaction — `AcquireOnApproachDelay` (the intelligence gradient)** - The paragon `AcquireOnApproach` bool becomes a `TimeSpan` on every creature: an enemy moving inside `AcquireOnApproachRange` (10 for all creatures — on-screen reactive aggro; the periodic scan keeps the wide `RangePerception` sweep) *clamps* the next scan to at most the delay. Repeated steps cannot shorten it further — one scan per delay period, not per step or think. - `Zero` (paragons) also prods the AI timer: the ranked scan engages within a wheel turn — the old snap, minus the special-cased engage path. The target now comes from the normal FightMode ranking instead of whichever mobile happened to move, and the `Combatant == null` guard stops re-engage spam. - The 2 s default reads as "took a beat to notice you"; larger values are dumber; `ReacquireDelay` alone is the oblivious floor. Mover checks are the approach logic's `IsEnemy` + `CanBeHarmful` (so pets count and hidden movers are excluded via `CanSee`), with `IsEnemy` first to cheaply reject same-team wild creatures wandering past. The check rides the `OnMovement` callback every step already pays for — no polling added. **Gate correctness** - Every scan re-arms the full `ReacquireDelay`, success or failure (classic semantics; reaction time is the approach path, not the poll). - Self-healing by construction: a deadline further out than `ReacquireDelay` is an illegal state and reads as open — no wedged or wrapped value can silence acquisition beyond one delay period. - `NextReacquireTime` is seeded from a live tick on deserialize (the GCP negative-tick blackout). **AI timer wake** - Activation (sector wake, spawn, resurrection) starts within a 0–256 ms spread instead of the 0–3 s construction stagger, which read as lag. - The stagger's real job — keeping same-speed cohorts out of lock-step (the RunUO town artifact) — is now a zero-mean ±period/8 jitter on each **idle** think, so phases random-walk apart within seconds and can never re-lock. Instrumentation showed why a one-shot spread can't do this job: the timer wheel fires within ±1 ms, so with 10 creatures on a 500 ms period some pair collides on nearly the same phase ~75% of the time (birthday paradox) and then steps in the same loop iteration *forever*. Jitter is scoped to passive speed: engaged cadence stays exact, since pursuit timing anchors to real step times. **Debug** - The `AcquireFocusMob` scan message no longer re-arms the shared 5 s debug cooldown, which swallowed every AI's "I have detected X" transition line. **API change** for custom scripts: `AcquireOnApproach` (bool) → `AcquireOnApproachDelay` (TimeSpan). Documented in `content-patterns.md` § Target Acquisition, `runuo-migration-docs/09` + `11`, and the migration skill checklist. ### Tests `AcquisitionTests`: both scan outcomes honor `ReacquireDelay`; a 60 s-wedged gate still acquires; enemy movement clamps the deadline (same-team wild movers and out-of-range movers ignored); repeated movement cannot shorten below the delay; `Zero` opens the gate and prods without a direct engage. Full suite: 755 UOContent green. --- .../Tests/Mobiles/AI/AcquisitionTests.cs | 211 ++++++++++++++++++ Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 23 +- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 24 +- Projects/UOContent/Mobiles/AI/BerserkAI.cs | 2 +- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 16 +- Projects/UOContent/Mobiles/BaseCreature.cs | 61 +++-- .../migrate-items-mobiles.md | 1 + .../modernuo-content-patterns.md | 4 +- dev-docs/content-patterns.md | 18 ++ .../09-items-mobiles-creatures.md | 20 ++ .../runuo-migration-docs/11-api-reference.md | 1 + 12 files changed, 340 insertions(+), 43 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs new file mode 100644 index 000000000..3ee516dcf --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the +// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero = +// prodded scan); an illegal deadline self-heals. +[Collection("Sequential Pathfinding Tests")] +public class AcquisitionTests : IDisposable +{ + private readonly List _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/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index 23ce0549c..803587ceb 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} and I will attack"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index d5a84f94d..e11268e87 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -31,8 +31,8 @@ public sealed class AITimer : Timer private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; - public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), - TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) + // The initial delay is irrelevant: Activate is the only start path and sets its own. + public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; @@ -48,7 +48,11 @@ public sealed class AITimer : Timer return; } - Start(); // keeps the stagger Delay + // Short random spread: the creature responds within a think while a sector's + // worth of timers avoids a same-tick burst; the idle think jitter keeps the + // cohort apart from there. + Delay = TimeSpan.FromMilliseconds(Utility.Random(256)); + Start(); _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } @@ -148,7 +152,18 @@ public sealed class AITimer : Timer } // Cadence from the post-decision speed (decisions may flip active/passive). - _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + var period = (long)(_owner.Mobile.CurrentSpeed * 1000); + _nextThink = Core.TickCount + period; + + // Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so + // creatures spawned or woken together cannot stay in lock-step (a one-shot + // spread can collide and identical periods never separate). Engaged cadence + // stays exact — pursuit timing anchors to real step times. + if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed) + { + var jitter = (int)(period >> 3); + _nextThink += Utility.RandomMinMax(-jitter, jitter); + } } else { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 3c2a479e0..4b8753d10 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -850,22 +850,24 @@ public abstract partial class BaseAI return false; } - if (Core.TickCount - Mobile.NextReacquireTime < 0) + var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; + var gateRemaining = Mobile.NextReacquireTime - Core.TickCount; + + if (gateRemaining > 0 && gateRemaining <= reacquireDelay) { Mobile.FocusMob = null; return false; } - Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds; + DebugSay("Acquiring new target...", 0); - DebugSay("Acquiring new target..."); + var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); - if (Mobile.Map == null) - { - return Mobile.FocusMob != null; - } + // Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach), + // not this poll — every scan honors the full delay. + Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; - return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); + return acquired; } private bool HandleBardProvoked() @@ -941,8 +943,10 @@ public abstract partial class BaseAI private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe) { - Mobile newFocusMob = null, enemySummonMob = null; - double val = double.MinValue, enemySummonVal = double.MinValue; + Mobile newFocusMob = null; + Mobile enemySummonMob = null; + var val = double.MinValue; + var enemySummonVal = double.MinValue; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index ff00ec91d..8a2015f1a 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} and I will attack"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index a71d83d5c..a6d0a9bf9 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace Server.Mobiles; public class MeleeAI : BaseAI @@ -14,6 +16,7 @@ public class MeleeAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } @@ -65,13 +68,9 @@ public class MeleeAI : BaseAI return true; } - private bool IsValidCombatant(Mobile combatant) - { - return combatant?.Deleted == false - && combatant.Map == Mobile.Map - && combatant.Alive - && !combatant.IsDeadBondedPet; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsValidCombatant(Mobile combatant) => + combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet; private bool HandleOutOfRangeCombatant(Mobile combatant) { @@ -127,7 +126,8 @@ public class MeleeAI : BaseAI { if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking."); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 05c728cf4..581d472e3 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -936,21 +936,50 @@ namespace Server.Mobiles public virtual bool GivesMLMinorArtifact => false; - /* To save on cpu usage, RunUO creatures only reacquire creatures under the following circumstances: - * - 10 seconds have elapsed since the last time it tried - * - The creature was attacked - * - Some creatures, like dragons, will reacquire when they see someone move - * - * This functionality appears to be implemented on OSI as well - */ - public long NextReacquireTime { get; set; } public virtual TimeSpan ReacquireDelay => TimeSpan.FromSeconds(10.0); - public virtual bool ReacquireOnMovement => false; - public virtual bool AcquireOnApproach => m_Paragon; + + // Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the + // next scan to at most this far away. Zero (paragons) scans on the very next + // think; larger is dumber; pure ReacquireDelay is the oblivious floor. + public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); + + // Reactive range is tighter than the periodic scan's RangePerception: approach + // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. public virtual int AcquireOnApproachRange => 10; + // Clamps the scan deadline rather than opening the gate: repeated steps cannot + // shorten it further, so an armed creature scans once per delay period. + private void ScheduleAcquireOnApproach() + { + var delay = (long)AcquireOnApproachDelay.TotalMilliseconds; + var deadline = Core.TickCount + delay; + + if (deadline - NextReacquireTime < 0) + { + NextReacquireTime = deadline; + } + + if (delay <= 0) + { + // Zero: think now — the ranked scan engages within a wheel turn. Prod is + // spam-safe; the Combatant == null guard stops the prods once engaged. + AIObject?.AITimer?.Prod(); + } + } + + // IsEnemy first — it cheaply rejects the common case (a same-team wild creature + // wandering past); CanBeHarmful covers hidden movers via CanSee. + private bool ShouldAcquireOnApproach(Mobile m) => + Combatant == null && + !Controlled && !Summoned && !BardPacified && + FightMode != FightMode.None && FightMode != FightMode.Aggressor && + InRange(m.Location, AcquireOnApproachRange) && + IsEnemy(m) && CanBeHarmful(m, false); + + public virtual bool ReacquireOnMovement => false; + public static bool Summoning { get; set; } public virtual bool IsDispellable => Summoned && !IsAnimatedDead; @@ -2024,6 +2053,8 @@ namespace Server.Mobiles { base.Deserialize(reader); + NextReacquireTime = Core.TickCount; + var version = reader.ReadInt(); m_CurrentAI = (AIType)reader.ReadInt(); @@ -2855,15 +2886,9 @@ namespace Server.Mobiles public override void OnMovement(Mobile m, Point3D oldLocation) { - if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor) + if (ShouldAcquireOnApproach(m)) { - if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) && - CanBeHarmful(m) && IsEnemy(m)) - { - Combatant = FocusMob = m; - AIObject?.MoveTo(m, 1); - DoHarmful(m); - } + ScheduleAcquireOnApproach(); } else if (ReacquireOnMovement) { 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 ce70ad26c..f12f92c55 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 @@ -30,6 +30,7 @@ description: > - `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 ## 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 2b6df7d55..6e0902b64 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,7 +29,9 @@ description: > 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 + 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 diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 3d11efb16..6205ec2d4 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -295,6 +295,24 @@ flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`, 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 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 7d4581b20..5b30e7ef9 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -497,6 +497,26 @@ An isolated step (after the creature stood for at least a walk interval) goes ou 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. + ## 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 358bf13c7..96db1590d 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -133,6 +133,7 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `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 | ## Networking From 708a35433700152ee407c3acc8e2a7de67c9e800 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:23:32 -0700 Subject: [PATCH 18/20] perf: stop allocating stat/skill mod lists for every mobile (#2604) ## Summary `_statMods` and `_skillMods` are created lazily by `AddStatMod` / `AddSkillMod` and nulled when they empty, and every reader already null-checks. The eager `new List()` in `DefaultMobileInit` and `Deserialize` therefore allocated two dead 32-byte objects for every mobile. On a ~500k-mobile world that is ~32 MB and 1M gen2 objects that hold nothing. - Removes the four eager allocations. - Removes the `StatMods` accessor (no references). - Documents `SkillMods` as `null` when no mods are active (its one caller in `Skills.cs` already checks). First of three PRs from the lazy per-mobile collections design; `DamageEntries` and `Aggressors`/`Aggressed` follow separately. ## Breaking change - `Mobile.SkillMods` may now be `null` (it was never null after construction before). External callers that enumerate it or read `.Count` must null-check. - `Mobile.StatMods` is removed. Use `GetStatMod(name)` / `AddStatMod` / `RemoveStatMod`. Save format is untouched: neither list is serialized. ## Testing - `dotnet build -c Release` clean. - New `MobileLazyModListTests` plus full `Server.Tests` (840) and `UOContent.Tests` (756). --- Projects/Server/Mobiles/Mobile.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 208142225..0407d7f82 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -6478,9 +6478,6 @@ 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()) @@ -7813,8 +7810,6 @@ 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(); From e52d54b7dacaadca3f4051e6a741c28ce19df1ee Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:25:14 -0700 Subject: [PATCH 19/20] perf: keep damage entries in an inline intrusive list (#2605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `Mobile.DamageEntries` was a `List` allocated for every mobile, including the ~99% that never take damage. It is now an inline `ValueLinkList` (24 bytes in the `Mobile` object, no separate allocation) ordered least recent → most recent. - `DamageEntry` implements `IValueLinkListNode`. - `RegisterDamage` moves the entry to the tail in O(1) instead of `Remove` + `Add` on a list. - Expired entries are always a head prefix, so pruning walks from the head and stops at the first live entry. The `DamageEntries` getter prunes on access. - `DamageEntries` is exposed as `ref readonly`; enumerate with `foreach` or `.ByDescending()`. Mutation goes through `RegisterDamage` / `ClearDamageEntries`. - `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` take `in ValueLinkList`; all callers compile unchanged. Files that `foreach` over `DamageEntries` need `using Server.Collections;` for the enumerator extension. - RunUO migration docs (`dev-docs/runuo-migration-docs/09`, `11`) and the `migrate-items-mobiles` skill document the change. Saves one object and 16 bytes per mobile (~8 MB and 500k gen2 objects on a 500k world). Second of three PRs from the lazy per-mobile collections design (first: #2604). Branched from `main`; the two diffs touch disjoint hunks of `Mobile.cs`. ## Breaking change - `Mobile.DamageEntries` is no longer a `List`. Indexing, `.Clear()`, `.Add()`, `.Remove()` no longer compile; use `foreach`, `.ByDescending()`, `.Count`, `ClearDamageEntries()`, and `RegisterDamage`. Calling a `ValueLinkList` mutator on the `ref readonly` property compiles but operates on a copy while still unlinking the real nodes; do not. - `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` signatures changed to `(in ValueLinkList, …)`. Save format is untouched: damage entries are not serialized. ## Behavior Recency order, `allowSelf`, tie-breaking in `FindMostTotal`/`FindLeastTotal` (most recent wins), `Responsible` accounting, and loot-rights ordering are unchanged and covered by the new `DamageEntryTests` and `LootingRightsTests`. ## Testing - `dotnet build -c Release` clean. - New `DamageEntryTests` and `LootingRightsTests` plus full `Server.Tests` and `UOContent.Tests`. --- .../Tests/Mobiles/DamageEntryTests.cs | 311 ++++++++++++++++++ Projects/Server/Mobiles/Mobile.cs | 174 +++++----- .../Tests/Mobiles/LootingRightsTests.cs | 146 ++++++++ .../Engines/CannedEvil/ChampionSpawn.cs | 6 +- Projects/UOContent/Mobiles/BaseCreature.cs | 25 +- .../UOContent/Mobiles/Special/Harrower.cs | 1 + .../migrate-items-mobiles.md | 1 + .../09-items-mobiles-creatures.md | 39 +++ .../runuo-migration-docs/11-api-reference.md | 3 + 9 files changed, 603 insertions(+), 103 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs diff --git a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs new file mode 100644 index 000000000..9c3cdfd14 --- /dev/null +++ b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using Server.Collections; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class DamageEntryTests +{ + private class TestMobile : Mobile + { + } + + private class PetMobile : Mobile + { + public Mobile Master { get; set; } + + public override Mobile GetDamageMaster(Mobile damagee) => Master; + } + + private static List 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 0407d7f82..d1db8113b 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 +public class DamageEntry : IValueLinkListNode { public DamageEntry(Mobile damager) => Damager = damager; @@ -57,6 +57,11 @@ public class DamageEntry 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] @@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; - DamageEntries = new List(); } // Sectors @@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static VisibleDamageType VisibleDamageType { get; set; } - public List DamageEntries { get; private 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; + } + } [CommandProperty(AccessLevel.GameMaster)] public Mobile LastKiller { get; set; } @@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors[i].CanReportMurder = false; } - if (DamageEntries.Count > 0) - { - DamageEntries.Clear(); // reset damage entries on full HP - } + ClearDamageEntries(); // reset damage entries on full HP } else if (CanRegenHits) { @@ -5745,24 +5762,54 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } + // Entries are kept in LastDamage order, so expired entries are always a head prefix. + private void PruneExpiredDamageEntries() + { +#if DEBUG + for (var node = _damageEntries._first; node != null; node = node.Next) + { + Debug.Assert( + node.Next == null || node.Next.LastDamage >= node.LastDamage, + "Damage entries must be ordered by LastDamage ascending." + ); + } +#endif + + var first = _damageEntries._first; + + if (first?.HasExpired != true) + { + return; + } + + var firstLive = first.Next; + + while (firstLive?.HasExpired == true) + { + firstLive = firstLive.Next; + } + + if (firstLive == null) + { + _damageEntries.RemoveAll(); + } + else + { + _damageEntries.RemoveAllBefore(firstLive); + } + } + + public void ClearDamageEntries() => _damageEntries.RemoveAll(); + public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager; public DamageEntry FindMostRecentDamageEntry(bool allowSelf) { - for (var i = DamageEntries.Count - 1; i >= 0; --i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if (allowSelf || de.Damager != this) + if (allowSelf || de.Damager != this) { return de; } @@ -5775,21 +5822,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) { - for (var i = 0; i < DamageEntries.Count; ++i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._first; de != null; de = de.Next) { - if (i < 0) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - --i; - } - else if (allowSelf || de.Damager != this) + if (allowSelf || de.Damager != this) { return de; } @@ -5800,24 +5837,17 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; + // Walks most recent first with a strict comparison so the most recent entry wins ties, + // matching the previous reverse-indexed loop. public DamageEntry FindMostTotalDamageEntry(bool allowSelf) { + PruneExpiredDamageEntries(); + DamageEntry mostTotal = null; - for (var i = DamageEntries.Count - 1; i >= 0; --i) + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) + if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) { mostTotal = de; } @@ -5830,46 +5860,28 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) { - DamageEntry mostTotal = null; + PruneExpiredDamageEntries(); - for (var i = DamageEntries.Count - 1; i >= 0; --i) + DamageEntry leastTotal = null; + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) + if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven)) - { - mostTotal = de; + leastTotal = de; } } - return mostTotal; + return leastTotal; } public DamageEntry FindDamageEntryFor(Mobile m) { - for (var i = DamageEntries.Count - 1; i >= 0; --i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if (de.Damager == m) + if (de.Damager == m) { return de; } @@ -5887,8 +5899,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro de.DamageGiven += amount; de.LastDamage = Core.Now; - DamageEntries.Remove(de); - DamageEntries.Add(de); + // Move to the tail so the list stays in LastDamage order. + if (de.OnLinkList) + { + _damageEntries.Remove(de); + } + + _damageEntries.AddLast(de); var master = from.GetDamageMaster(this); @@ -7814,7 +7831,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro AutoPageNotify = true; Aggressors = new List(); Aggressed = new List(); - DamageEntries = new List(); NextSkillTime = Core.TickCount; } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs new file mode 100644 index 000000000..e02abece7 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs @@ -0,0 +1,146 @@ +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/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 9daea0ba3..3a024576b 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -18,6 +18,7 @@ using System.Net; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; +using Server.Collections; using Server.Engines.Virtues; using Server.Gumps; using Server.Items; @@ -1181,11 +1182,6 @@ public partial class ChampionSpawn : Item foreach (var de in m.DamageEntries) { - if (de.HasExpired) - { - continue; - } - var damager = de.Damager; var master = damager.GetDamageMaster(m); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 581d472e3..404aaede8 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3123,14 +3123,12 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - public int ComputeBonusDamage(List list, Mobile m) + public int ComputeBonusDamage(in ValueLinkList list, Mobile m) { var bonus = 0; - for (var i = list.Count - 1; i >= 0; --i) + foreach (var de in list.ByDescending()) { - var de = list[i]; - if (de.Damager == m || de.Damager is not BaseCreature bc) { continue; @@ -3167,26 +3165,15 @@ namespace Server.Mobiles Combatant is PlayerMobile || Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile; - public static List GetLootingRights(List damageEntries, int hitsMax) + // Iterates most recent first, matching the previous reverse-indexed loop. The list is + // already pruned of expired entries by the Mobile.DamageEntries getter. + public static List GetLootingRights(in ValueLinkList damageEntries, int hitsMax) { var rights = new List(); DamageStore firstDamager = null; - for (var i = damageEntries.Count - 1; i >= 0; --i) + foreach (var de in damageEntries.ByDescending()) { - if (i >= damageEntries.Count) - { - continue; - } - - var de = damageEntries[i]; - - if (de.HasExpired) - { - damageEntries.RemoveAt(i); - continue; - } - var damage = de.DamageGiven; var respList = de.Responsible; diff --git a/Projects/UOContent/Mobiles/Special/Harrower.cs b/Projects/UOContent/Mobiles/Special/Harrower.cs index ee3fa1873..8d87943bc 100644 --- a/Projects/UOContent/Mobiles/Special/Harrower.cs +++ b/Projects/UOContent/Mobiles/Special/Harrower.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; +using Server.Collections; using Server.Engines.CannedEvil; using Server.Engines.Virtues; using Server.Items; 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 f12f92c55..7310c104e 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 @@ -31,6 +31,7 @@ description: > - 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/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index 5b30e7ef9..def2ecf48 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -517,6 +517,45 @@ 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 96db1590d..b6c12bfe8 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -134,6 +134,9 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `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 From 25a2aa03c5cbe608eeed61ceaad02f5e6dc02d7e Mon Sep 17 00:00:00 2001 From: WarrentyExpired Date: Wed, 2 Sep 2026 10:16:24 -0400 Subject: [PATCH 20/20] #W# Update: added Distribution/Data/Files to gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d5b26e268..d29ee313a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Distribution Files +/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO