From a2c232f4a84add9922667db33650d70233422c38 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:39:00 -0700 Subject: [PATCH 01/21] fix: Removes side effects from locked down items, decaying houses, bracelet of binding, and builds (#2608) ## Why Delta world saves (only changed entities re-serialized during the freeze) need two things from content: `Serialize` must be pure, and an unchanged entity must produce identical bytes on every save. An audit of the tree found the places that break this today. This PR fixes them and adds a diagnostic that measures byte stability in a running world over real time. No wire format changes, no version bumps. ## What - **Guild**: `Serialize` ran the daily guildmaster recalculation (dictionary-order tie breaks, random picks, mutates another entity), war expiry, and the alliance leadership check on every save. That work now runs from `GuildMaintenanceTimer` (the former `WarTimer`, now started for both guild systems; the war and alliance checks are no-ops without the new guild system). - **BaseHouse**: the `DecayLevel` getter wrote `LastRefreshed` and advanced the dynamic decay stage on every read, so any undecayable house changed its persisted bytes whenever a sign, gump, or `[Props` looked at it. It is now a pure read; `UpdateDecay()` on the decay tick advances the stage and refreshes a house that leaves the undecayable state. - **Locked-down container contents decay through `DecayScheduler`** instead of a sweep inside `BaseHouse.Serialize`. `Item.CanDecay` now accepts a parent container whose new `Container.ContentsDecay` is true (locked down and not secure) when the content item is not itself locked down or secured; the `IsLockedDown`/`IsSecure` setters re-evaluate a container and its direct contents; `OnDecay` resolves the region from the world location. `BaseBoard`, `Aquarium` and `FishBowl` override `ContentsDecay` to false, matching the exclusions of the old sweep. Items already inside such containers are picked up at load, where every item re-evaluates its registration. The per-save O(all locked-down items) walk is gone; the work is now event driven. - **BraceletOfBinding**: the `Bound` getter nulled its backing field for a deleted target; it now returns null for a missing or deleted target without writing. - **`[SaveStability [delaySeconds=60] [sampleStride=1]`** (Administrator): hashes the serialized bytes of every entity in every registered entity persistence (`Persistence.EntityPersistences`, new), waits the delay in real time, hashes again, and reports per type how many records changed. Both passes are chunked under a 20 ms per-tick budget. Serializing twice inside one tick cannot see drift derived from `Core.Now`, which is frozen within a tick; the real-time wait is the point. On an idle shard the only legitimate churn is NPC movement and regeneration, so a static type with a high changed fraction is a serialization bug. `[SaveStability cancel` stops a run. Engine additions: `Container.ContentsDecay` / `UpdateContentsDecayRegistration()`, the `CanDecay`/`OnDecay` change above, and diagnostic-facing `IGenericEntityPersistence.Name`, `EntityCount`, `EnumerateEntities()`, and `Persistence.EntityPersistences`. ## Behaviour notes - Fealty recalculation now happens on the minute timer for both guild systems instead of at save time. - A house that becomes decayable is refreshed on its next decay tick rather than at the exact moment of transition (at most one minute later). - Contents of locked-down containers now decay on their own schedule (same `LastMoved + DecayTime` rule) instead of at the next world save after becoming due. ## Testing - New tests: `GuildSerializePurityTests`, `HouseDecayPurityTests`, `SaveStabilityTests` (stable entities hash identically after the clock advances; a clock-stamped record is reported by type only after the clock advances; the snapshot covers Items, Mobiles and Guilds). - `LockedDownContainerDecayTests` (Server.Tests): registration on drop, lock/secure/release transitions, excluded containers, nested containers, and decay on schedule through the scheduler. - Full suites: Server.Tests 855 passed, UOContent.Tests 765 passed. --- .../Items/LockedDownContainerDecayTests.cs | 173 ++++++++++ Projects/Server/Items/Container.cs | 21 ++ Projects/Server/Items/Item.cs | 33 +- .../Serialization/GenericEntityPersistence.cs | 20 ++ Projects/Server/Serialization/Persistence.cs | 15 + .../Tests/Misc/GuildSerializePurityTests.cs | 61 ++++ .../Multis/Houses/HouseDecayPurityTests.cs | 42 +++ .../Tests/WorldSaves/SaveStabilityTests.cs | 152 +++++++++ .../Commands/SaveStabilityCommand.cs | 318 ++++++++++++++++++ Projects/UOContent/Items/Aquarium/Aquarium.cs | 2 + Projects/UOContent/Items/Aquarium/FishBowl.cs | 2 + Projects/UOContent/Items/Games/BaseBoard.cs | 2 + .../Special/Solen Items/BraceletOfBinding.cs | 11 +- Projects/UOContent/Misc/Guild.cs | 26 +- Projects/UOContent/Multis/Houses/BaseHouse.cs | 110 +++--- 15 files changed, 921 insertions(+), 67 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs create mode 100644 Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs create mode 100644 Projects/UOContent/Commands/SaveStabilityCommand.cs diff --git a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs new file mode 100644 index 000000000..b8b4fd419 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs @@ -0,0 +1,173 @@ +using System; +using Server.Items; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class LockedDownContainerDecayTests +{ + public LockedDownContainerDecayTests() + { + DecayScheduler.ResetForTests(); + + // Content assigns these at startup (BaseHouse); the engine tests have no content. + if (Item.LockedDownFlag == 0) + { + Item.LockedDownFlag = 1; + Item.SecureFlag = 2; + } + } + + private class TestChest : Container + { + public TestChest() : base(0xE43) + { + } + } + + private class KeepsContentsChest : Container + { + public KeepsContentsChest() : base(0xE43) + { + } + + public override bool ContentsDecay => false; + } + + private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true }; + + private static TestChest PlaceLockedDownChest(int x) + { + var chest = new TestChest { Movable = false }; + chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca); + chest.IsLockedDown = true; + return chest; + } + + [Fact] + public void ItemDroppedIntoLockedDownContainer_IsTracked() + { + var chest = PlaceLockedDownChest(120); + var loot = NewLoot(); + + chest.AddItem(loot); + + Assert.True(loot.CanDecay()); + Assert.True(DecayScheduler.IsRegistered(loot)); + + chest.Delete(); + } + + [Fact] + public void ItemInOrdinaryContainer_IsNotTracked() + { + var chest = new TestChest(); + chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); + var loot = NewLoot(); + + chest.AddItem(loot); + + Assert.False(loot.CanDecay()); + Assert.False(DecayScheduler.IsRegistered(loot)); + + chest.Delete(); + } + + [Fact] + public void LockingAndReleasingContainer_RegistersAndUnregistersContents() + { + var chest = new TestChest { Movable = false }; + chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca); + var loot = NewLoot(); + chest.AddItem(loot); + + Assert.False(DecayScheduler.IsRegistered(loot)); + + chest.IsLockedDown = true; + Assert.True(DecayScheduler.IsRegistered(loot)); + + chest.IsSecure = true; // secure containers keep their contents + Assert.False(DecayScheduler.IsRegistered(loot)); + + chest.IsSecure = false; + Assert.True(DecayScheduler.IsRegistered(loot)); + + chest.IsLockedDown = false; + Assert.False(DecayScheduler.IsRegistered(loot)); + + chest.Delete(); + } + + [Fact] + public void ContainerThatKeepsContents_DoesNotTrackThem() + { + var board = new KeepsContentsChest { Movable = false }; + board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca); + board.IsLockedDown = true; + var piece = NewLoot(); + + board.AddItem(piece); + + Assert.False(DecayScheduler.IsRegistered(piece)); + + board.Delete(); + } + + [Fact] + public void NestedContainerContents_AreNotTracked() + { + var chest = PlaceLockedDownChest(124); + var bag = new TestChest(); + chest.AddItem(bag); + var loot = NewLoot(); + bag.AddItem(loot); + + Assert.True(DecayScheduler.IsRegistered(bag)); + Assert.False(DecayScheduler.IsRegistered(loot)); + + chest.Delete(); + } + + [Fact] + public void LockedDownItemInsideLockedDownContainer_IsNotTracked() + { + var chest = PlaceLockedDownChest(125); + var loot = NewLoot(); + chest.AddItem(loot); + loot.IsLockedDown = true; + + Assert.False(DecayScheduler.IsRegistered(loot)); + + chest.Delete(); + } + + [Fact] + public void TrackedContents_DecayOnSchedule() + { + var start = Core._now; + + try + { + var chest = PlaceLockedDownChest(126); + var loot = NewLoot(); + chest.AddItem(loot); + + var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2); + for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256)) + { + Core._now = now; + DecayScheduler.ProcessTick(now); + } + + Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); + Assert.False(chest.Deleted, "The locked-down container itself must not decay."); + + chest.Delete(); + } + finally + { + Core._now = start; + } + } +} diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index 06c676b25..18aa27be5 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -153,6 +153,27 @@ public partial class Container : Item public virtual bool IsDecoContainer => !Movable && !IsLockedDown && !IsSecure && Parent == null && !LiftOverride; + /// + /// True when this container's direct contents decay on their own schedule: a locked-down, + /// non-secure container in a house. Containers whose contents are part of the object + /// (game boards, aquariums) override this to false. + /// + public virtual bool ContentsDecay => IsLockedDown && !IsSecure; + + /// + /// Re-evaluates decay registration for every direct child. Call when + /// may have changed. + /// + public void UpdateContentsDecayRegistration() + { + var items = Items; + + for (var i = 0; i < items.Count; i++) + { + items[i].UpdateDecayRegistration(); + } + } + public static int GlobalMaxItems { get; set; } = 125; public static int GlobalMaxWeight { get; set; } = 400; diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 13f249f2f..03ff9d000 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -426,8 +426,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert get => GetTempFlag(LockedDownFlag); set { + if (GetTempFlag(LockedDownFlag) == value) + { + return; + } + SetTempFlag(LockedDownFlag, value); InvalidateProperties(); + OnSecurityChanged(); } } @@ -437,8 +443,26 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert get => GetTempFlag(SecureFlag); set { + if (GetTempFlag(SecureFlag) == value) + { + return; + } + SetTempFlag(SecureFlag, value); InvalidateProperties(); + OnSecurityChanged(); + } + } + + // Lockdown and secure status decide whether this item and, for a container, its direct + // contents are decay-eligible (see CanDecay); re-evaluate both. + private void OnSecurityChanged() + { + UpdateDecayRegistration(); + + if (this is Container container) + { + container.UpdateContentsDecayRegistration(); } } @@ -2351,10 +2375,15 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public bool AtPoint(int x, int y) => m_Location.m_X == x && m_Location.m_Y == y; - public virtual bool CanDecay() => Decays && Parent == null && Map != Map.Internal; + // Ground items decay; so do the direct contents of a container whose ContentsDecay is true + // (a locked-down, non-secure house container), unless the content item is itself locked + // down or secured. Nested containers do not propagate: only direct children qualify. + public virtual bool CanDecay() => + Decays && Map != Map.Internal && + (Parent == null || Parent is Container { ContentsDecay: true } && !IsLockedDown && !IsSecure); public virtual bool OnDecay() => - CanDecay() && Region.Find(Location, Map).OnDecay(this); + CanDecay() && Region.Find(GetWorldLocation(), Map).OnDecay(this); public DateTime ScheduledDecayTime { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index c7323329f..9a1490504 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -28,7 +28,17 @@ namespace Server; public interface IGenericEntityPersistence { + string Name { get; } + + int EntityCount { get; } + void DeserializeIndexes(string savePath, Dictionary typesDb); + + /// + /// Enumerates the live entities. Diagnostics only: the dictionary must not be mutated while + /// enumerating, so callers snapshot the sequence before doing anything that can add or delete. + /// + IEnumerable EnumerateEntities(); } public class GenericEntityPersistence : GenericPersistence, IGenericEntityPersistence, ISlotRangeSource @@ -85,6 +95,16 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer public Dictionary EntitiesBySerial { get; } = new(); + public int EntityCount => EntitiesBySerial.Count; + + public IEnumerable EnumerateEntities() + { + foreach (var entity in EntitiesBySerial.Values) + { + yield return entity; + } + } + public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this( name, priority, diff --git a/Projects/Server/Serialization/Persistence.cs b/Projects/Server/Serialization/Persistence.cs index 35d1d7514..edd1b519c 100644 --- a/Projects/Server/Serialization/Persistence.cs +++ b/Projects/Server/Serialization/Persistence.cs @@ -34,6 +34,21 @@ public abstract class Persistence public bool Register() => _registry.Add(this); + /// Every registered entity persistence (Items, Mobiles, Guilds, Accounts, ...), in priority order. + public static IEnumerable EntityPersistences + { + get + { + foreach (var entry in _registry) + { + if (entry is IGenericEntityPersistence entityPersistence) + { + yield return entityPersistence; + } + } + } + } + public void Unregister() => _registry.Remove(this); public static void Load(string path) diff --git a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs new file mode 100644 index 000000000..f596282f6 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Server; +using Server.Guilds; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class GuildSerializePurityTests +{ + private static PlayerMobile CreatePlayer() + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + return m; + } + + [Fact] + public void SerializeDoesNotRecalculateFealtyOrMutateState() + { + var leader = CreatePlayer(); + var guild = new Guild(leader, "Purity Test Guild", "PTG"); + var staleFealty = Core.Now - TimeSpan.FromDays(3); + guild.LastFealty = staleFealty; + + var writer = new BufferWriter(true); + guild.Serialize(writer); + + Assert.Equal(staleFealty, guild.LastFealty); + Assert.Same(leader, guild.Leader); + + var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + writer.Seek(0, SeekOrigin.Begin); + guild.Serialize(writer); + var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + + Assert.Equal(first, second); + + writer.Close(); + guild.Disband(); + leader.Delete(); + } + + [Fact] + public void RunMaintenanceRecalculatesStaleFealty() + { + var leader = CreatePlayer(); + var guild = new Guild(leader, "Maintenance Test Guild", "MTG"); + guild.LastFealty = Core.Now - TimeSpan.FromDays(3); + + guild.RunMaintenance(); + + Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + + guild.Disband(); + leader.Delete(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs b/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs new file mode 100644 index 000000000..f8e1cb3f2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs @@ -0,0 +1,42 @@ +using System; +using Server; +using Server.Mobiles; +using Server.Multis; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class HouseDecayPurityTests +{ + [Fact] + public void ReadingDecayLevelDoesNotStampLastRefreshed() + { + var decayEnabled = BaseHouse.DecayEnabled; + BaseHouse.DecayEnabled = false; // every house is Ageless, the branch that used to restamp on read + + var owner = new PlayerMobile(World.NewMobile); + owner.DefaultMobileInit(); + owner.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca); + + var house = new SmallOldHouse(owner, 0x64); + + try + { + house.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca); + + var placed = Core.Now - TimeSpan.FromDays(2); + house.LastRefreshed = placed; + + Assert.False(house.CanDecay); + Assert.Equal(DecayLevel.Ageless, house.DecayLevel); + Assert.Equal(placed, house.LastRefreshed); + } + finally + { + house.Delete(); + owner.Delete(); + BaseHouse.DecayEnabled = decayEnabled; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs new file mode 100644 index 000000000..978adcc3f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class SaveStabilityTests +{ + // Serializes the current time, the classic way a record drifts between saves with no mutation. + private sealed class ClockStampedItem : Item + { + public ClockStampedItem() : base(0x1F03) + { + } + + public ClockStampedItem(Serial serial) : base(serial) + { + } + + public override void Serialize(IGenericWriter writer) + { + base.Serialize(writer); + writer.Write(Core.Now); + } + + public override void Deserialize(IGenericReader reader) + { + base.Deserialize(reader); + reader.ReadDateTime(); + } + } + + private sealed class CreatureStub : BaseCreature + { + public CreatureStub() : base(AIType.AI_Animal, FightMode.Closest, 10, 1) + { + Body = 0xEE; // rat + } + + public CreatureStub(Serial serial) : base(serial) + { + } + + // NPCSpeeds is not configured in the test fixture; the AIType ctor would hit the empty table. + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } + + [Fact] + public void StableEntitiesHashIdenticallyAfterTimePasses() + { + var bag = new Bag(); + bag.DropItem(new Gold(100)); + bag.DropItem(new Dagger()); + bag.MoveToWorld(new Point3D(1450, 1450, 0), Map.Felucca); + + var creature = new CreatureStub(); + creature.MoveToWorld(new Point3D(1451, 1450, 0), Map.Felucca); + + var entities = new List { bag, creature }; + entities.AddRange(bag.Items); + + var now = Core._now; + + try + { + var captured = SaveStability.CaptureHashes(entities); + Core._now = now + TimeSpan.FromMinutes(5); + + var report = SaveStability.Compare(entities, captured); + + Assert.Equal(entities.Count, report.Checked); + Assert.Equal(0, report.Changed); + } + finally + { + Core._now = now; + bag.Delete(); + creature.Delete(); + } + } + + [Fact] + public void ClockDerivedRecordIsReportedByType() + { + var item = new ClockStampedItem(); + item.MoveToWorld(new Point3D(1452, 1450, 0), Map.Felucca); + + var now = Core._now; + + try + { + var entities = new List { item }; + var captured = SaveStability.CaptureHashes(entities); + + // Same tick: the drift is invisible, which is why the command waits in real time. + Assert.Equal(0, SaveStability.Compare(entities, captured).Changed); + + Core._now = now + TimeSpan.FromMinutes(1); + var report = SaveStability.Compare(entities, captured); + + Assert.Equal(1, report.Changed); + Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Changed); + Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Total); + } + finally + { + Core._now = now; + item.Delete(); + } + } + + [Fact] + public void SnapshotCoversEveryRegisteredEntityPersistence() + { + var item = new Bag(); + item.MoveToWorld(new Point3D(1453, 1450, 0), Map.Felucca); + var mobile = new CreatureStub(); + mobile.MoveToWorld(new Point3D(1454, 1450, 0), Map.Felucca); + + try + { + var snapshot = SaveStability.SnapshotEntities(); + + Assert.Contains(item, snapshot); + Assert.Contains(mobile, snapshot); + + var names = new List(); + foreach (var persistence in Persistence.EntityPersistences) + { + names.Add(persistence.Name); + } + + Assert.Contains("Items", names); + Assert.Contains("Mobiles", names); + Assert.Contains("Guilds", names); + } + finally + { + item.Delete(); + mobile.Delete(); + } + } +} diff --git a/Projects/UOContent/Commands/SaveStabilityCommand.cs b/Projects/UOContent/Commands/SaveStabilityCommand.cs new file mode 100644 index 000000000..0a0cd6d0f --- /dev/null +++ b/Projects/UOContent/Commands/SaveStabilityCommand.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Server.Logging; + +namespace Server.Commands; + +/// +/// Measures whether entities serialize to the same bytes over time when nothing changed them. +/// Hashes every entity of every registered entity persistence, waits, hashes again, and reports +/// per type how many records changed. Both passes are chunked across ticks so the loop never +/// stalls, and the wait spans real time so anything derived from (which +/// is frozen within a tick) shows up. On an idle shard the only legitimate churn is NPC movement +/// and regeneration; a static type with a high changed fraction is a serialization bug. +/// +public static class SaveStability +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(SaveStability)); + + private const int DefaultDelaySeconds = 60; + private static readonly TimeSpan TickBudget = TimeSpan.FromMilliseconds(20); + + private static Run _current; + + public sealed class TypeStats + { + public int Total; + public int Changed; + } + + public sealed record SaveStabilityReport( + int Checked, + int Changed, + Dictionary ByType + ); + + public static void Configure() + { + CommandSystem.Register("SaveStability", AccessLevel.Administrator, SaveStability_OnCommand); + } + + [Usage("SaveStability [delaySeconds=60] [sampleStride=1] | SaveStability cancel")] + [Description("Hashes every entity, waits, hashes again, and reports the types whose serialized bytes changed.")] + private static void SaveStability_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Length > 0 && e.GetString(0).InsensitiveEquals("cancel")) + { + if (_current != null) + { + _current.Cancel(); + _current = null; + from.SendMessage("Save stability check cancelled."); + } + else + { + from.SendMessage("No save stability check is running."); + } + + return; + } + + if (_current != null) + { + from.SendMessage("A save stability check is already running. Use [SaveStability cancel to stop it."); + return; + } + + var delaySeconds = e.Length > 0 ? e.GetInt32(0) : DefaultDelaySeconds; + var stride = e.Length > 1 ? e.GetInt32(1) : 1; + + if (delaySeconds < 1 || stride < 1) + { + from.SendMessage("Usage: [SaveStability [delaySeconds] [sampleStride]"); + return; + } + + _current = new Run(from, TimeSpan.FromSeconds(delaySeconds), stride); + _current.Start(); + } + + /// Snapshots every entity of every registered entity persistence, taking every Nth. + public static List SnapshotEntities(int stride = 1) + { + var list = new List(); + var i = 0; + + foreach (var persistence in Persistence.EntityPersistences) + { + foreach (var entity in persistence.EnumerateEntities()) + { + if (i++ % stride == 0) + { + list.Add(entity); + } + } + } + + return list; + } + + /// Hashes the serialized bytes of every entity in order; deleted entities hash to 0. + public static ulong[] CaptureHashes(List entities) + { + var hashes = new ulong[entities.Count]; + var writer = new BufferWriter(true); + + for (var i = 0; i < entities.Count; i++) + { + hashes[i] = Hash(entities[i], writer); + } + + writer.Close(); + return hashes; + } + + /// Re-hashes every entity and reports, per type, how many differ from the captured hashes. + public static SaveStabilityReport Compare(List entities, ulong[] captured) + { + var writer = new BufferWriter(true); + var report = new SaveStabilityReport(0, 0, new Dictionary()); + var checkedCount = 0; + var changed = 0; + + for (var i = 0; i < entities.Count; i++) + { + var entity = entities[i]; + if (entity.Deleted || captured[i] == 0) + { + continue; + } + + checkedCount++; + var type = entity.GetType(); + + if (!report.ByType.TryGetValue(type, out var stats)) + { + report.ByType[type] = stats = new TypeStats(); + } + + stats.Total++; + + if (Hash(entity, writer) != captured[i]) + { + stats.Changed++; + changed++; + } + } + + writer.Close(); + return report with { Checked = checkedCount, Changed = changed }; + } + + private static ulong Hash(ISerializable entity, BufferWriter writer) + { + if (entity.Deleted) + { + return 0; + } + + writer.Seek(0, SeekOrigin.Begin); + entity.Serialize(writer); + return HashUtility.ComputeHash64(writer.Buffer.AsSpan(0, (int)writer.Position)); + } + + // Drives capture -> wait -> compare across ticks under a fixed time budget per tick. + private sealed class Run + { + private readonly Mobile _from; + private readonly TimeSpan _delay; + private readonly int _stride; + private readonly BufferWriter _writer = new(true); + + private List _entities; + private ulong[] _hashes; + private SaveStabilityReport _report; + private int _index; + private bool _comparing; + private Timer _timer; + + public Run(Mobile from, TimeSpan delay, int stride) + { + _from = from; + _delay = delay; + _stride = stride; + } + + public void Start() + { + _entities = SnapshotEntities(_stride); + _hashes = new ulong[_entities.Count]; + + _from.SendMessage($"Save stability: hashing {_entities.Count} entities across ticks..."); + _timer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.Zero, Step); + } + + public void Cancel() + { + _timer?.Stop(); + _timer = null; + _writer.Close(); + } + + private void Step() + { + var started = Stopwatch.GetTimestamp(); + + while (_index < _entities.Count) + { + var entity = _entities[_index]; + + if (_comparing) + { + CompareOne(entity, _index); + } + else + { + _hashes[_index] = Hash(entity, _writer); + } + + _index++; + + if (Stopwatch.GetElapsedTime(started) >= TickBudget) + { + return; + } + } + + _timer.Stop(); + _timer = null; + + if (!_comparing) + { + _from.SendMessage($"Save stability: captured. Comparing again in {_delay.TotalSeconds:F0}s."); + _comparing = true; + _index = 0; + _report = new SaveStabilityReport(0, 0, new Dictionary()); + _timer = Timer.DelayCall(_delay, TimeSpan.Zero, Step); + return; + } + + Finish(); + } + + private void CompareOne(ISerializable entity, int index) + { + if (entity.Deleted || _hashes[index] == 0) + { + return; + } + + var type = entity.GetType(); + + if (!_report.ByType.TryGetValue(type, out var stats)) + { + _report.ByType[type] = stats = new TypeStats(); + } + + stats.Total++; + + if (Hash(entity, _writer) != _hashes[index]) + { + stats.Changed++; + } + } + + private void Finish() + { + _writer.Close(); + _current = null; + + var checkedCount = 0; + var changed = 0; + var ranked = new List>(); + + foreach (var pair in _report.ByType) + { + checkedCount += pair.Value.Total; + changed += pair.Value.Changed; + + if (pair.Value.Changed > 0) + { + ranked.Add(pair); + } + } + + ranked.Sort(static (a, b) => b.Value.Changed.CompareTo(a.Value.Changed)); + + _from.SendMessage($"Save stability: {changed} of {checkedCount} entities changed over {_delay.TotalSeconds:F0}s."); + logger.Information( + "Save stability: {Changed} of {Checked} entities changed over {Delay}s ({Types} types)", + changed, + checkedCount, + _delay.TotalSeconds, + ranked.Count + ); + + var shown = 0; + foreach (var (type, stats) in ranked) + { + var percent = stats.Changed * 100.0 / stats.Total; + logger.Information(" {Type}: {Changed}/{Total} ({Percent:F1}%)", type.FullName, stats.Changed, stats.Total, percent); + + if (shown++ < 25) + { + _from.SendMessage($" {type.Name}: {stats.Changed}/{stats.Total} ({percent:F1}%)"); + } + } + + if (ranked.Count > 25) + { + _from.SendMessage($" ...{ranked.Count - 25} more types in the log."); + } + } + } +} diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 054fea588..39134e523 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -13,6 +13,8 @@ namespace Server.Items [SerializationGenerator(4, false)] public partial class Aquarium : BaseAddonContainer { + public override bool ContentsDecay => false; // fish and decorations are part of the aquarium + public static readonly TimeSpan EvaluationInterval = TimeSpan.FromDays(1); private static readonly Type[] m_Decorations = diff --git a/Projects/UOContent/Items/Aquarium/FishBowl.cs b/Projects/UOContent/Items/Aquarium/FishBowl.cs index 9d8375551..1cf2b7507 100644 --- a/Projects/UOContent/Items/Aquarium/FishBowl.cs +++ b/Projects/UOContent/Items/Aquarium/FishBowl.cs @@ -8,6 +8,8 @@ namespace Server.Items [SerializationGenerator(0, false)] public partial class FishBowl : BaseContainer { + public override bool ContentsDecay => false; // the fish is part of the bowl + [Constructible] public FishBowl() : base(0x241C) { diff --git a/Projects/UOContent/Items/Games/BaseBoard.cs b/Projects/UOContent/Items/Games/BaseBoard.cs index b13d7dde2..be437f9d9 100644 --- a/Projects/UOContent/Items/Games/BaseBoard.cs +++ b/Projects/UOContent/Items/Games/BaseBoard.cs @@ -11,6 +11,8 @@ namespace Server.Items; [SerializationGenerator(2, false)] public abstract partial class BaseBoard : Container, ISecurable { + public override bool ContentsDecay => false; // pieces are part of the board + [SerializedIgnoreDupe] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 1d508c4ac..6f8d26940 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -53,16 +53,15 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem [CommandProperty(AccessLevel.GameMaster)] public BraceletOfBinding Bound { - get + get => _bound?.Deleted != false ? null : _bound; + set { - if (_bound?.Deleted == true) + if (_bound != value) { - _bound = null; + _bound = value; + this.MarkDirty(); } - - return _bound; } - set => _bound = value; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Misc/Guild.cs b/Projects/UOContent/Misc/Guild.cs index 88ec44773..b2ce6d428 100644 --- a/Projects/UOContent/Misc/Guild.cs +++ b/Projects/UOContent/Misc/Guild.cs @@ -498,25 +498,22 @@ namespace Server.Guilds } } - public class WarTimer : Timer + public class GuildMaintenanceTimer : Timer { - public WarTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0)) + public GuildMaintenanceTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0)) { } public static void Initialize() { - if (Guild.NewGuildSystem) - { - new WarTimer().Start(); - } + new GuildMaintenanceTimer().Start(); } protected override void OnTick() { foreach (var g in World.Guilds.Values) { - (g as Guild)?.CheckExpiredWars(); + (g as Guild)?.RunMaintenance(); } } } @@ -1100,8 +1097,18 @@ namespace Server.Guilds list.TrimExcess(); } - public override void Serialize(IGenericWriter writer) + /// + /// Periodic bookkeeping that used to ride on every world save: the daily fealty + /// recalculation, war expiry, and the alliance leadership check. Saves must be pure, + /// so this runs from instead. + /// + public void RunMaintenance() { + if (Disbanded) + { + return; + } + if (LastFealty + TimeSpan.FromDays(1.0) < Core.Now) { CalculateGuildmaster(); @@ -1110,7 +1117,10 @@ namespace Server.Guilds CheckExpiredWars(); Alliance?.CheckLeader(); + } + public override void Serialize(IGenericWriter writer) + { writer.Write(5); // version writer.Write(PendingWars.Count); diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs index 19e9c020c..212a60386 100644 --- a/Projects/UOContent/Multis/Houses/BaseHouse.cs +++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs @@ -31,6 +31,7 @@ namespace Server.Multis private DecayLevel m_CurrentStage; private DecayLevel m_LastDecayLevel; + private bool _wasUndecayable; // not serialized: decay tick state only private Mobile m_Owner; @@ -198,19 +199,58 @@ namespace Server.Multis { get { - DecayLevel result; - if (!CanDecay) { + return DecayLevel.Ageless; + } + + if (DynamicDecay.Enabled) + { + var stage = m_CurrentStage; + + if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0)) + { + return DecayLevel.DemolitionPending; + } + + return stage; + } + + return GetOldDecayLevel(); + } + } + + /// + /// Advances decay bookkeeping. Runs from the decay tick, never from a read: a getter that + /// writes persisted fields makes every house record change on every read. + /// + public virtual void UpdateDecay() + { + if (!CanDecay) + { + if (DynamicDecay.Enabled && m_CurrentStage != DecayLevel.Ageless) + { + ResetDynamicDecay(); + } + + _wasUndecayable = true; + } + else + { + if (_wasUndecayable) + { + // Leaving the undecayable state counts as a refresh, which the old getter + // guaranteed by restamping LastRefreshed on every read while undecayable. + _wasUndecayable = false; + LastRefreshed = Core.Now; + if (DynamicDecay.Enabled) { ResetDynamicDecay(); } - - LastRefreshed = Core.Now; - result = DecayLevel.Ageless; } - else if (DynamicDecay.Enabled) + + if (DynamicDecay.Enabled) { var stage = m_CurrentStage; @@ -218,32 +258,19 @@ namespace Server.Multis { SetDynamicDecay(++stage); } - - if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0)) - { - result = DecayLevel.DemolitionPending; - } - else - { - result = stage; - } } - else + } + + var level = DecayLevel; + + if (level != m_LastDecayLevel) + { + m_LastDecayLevel = level; + + if (Sign?.GettingProperties == false) { - result = GetOldDecayLevel(); + Sign.InvalidateProperties(); } - - if (result != m_LastDecayLevel) - { - m_LastDecayLevel = result; - - if (Sign?.GettingProperties == false) - { - Sign.InvalidateProperties(); - } - } - - return result; } } @@ -582,6 +609,7 @@ namespace Server.Multis return false; } + UpdateDecay(); var oldLevel = DecayLevel; LastRefreshed = Core.Now; @@ -598,6 +626,8 @@ namespace Server.Multis public virtual bool CheckDecay() { + UpdateDecay(); + if (!Deleted && DecayLevel == DecayLevel.Collapsed) { Timer.StartTimer(Decay_Sandbox); @@ -2969,28 +2999,6 @@ namespace Server.Multis writer.Write(MaxLockDowns); writer.Write(MaxSecures); - - // Items in locked down containers that aren't locked down themselves must decay! - for (var i = 0; i < LockDowns.Count; ++i) - { - var item = LockDowns[i]; - - if (item is Container cont && !(cont is BaseBoard or Aquarium or FishBowl)) - { - var children = cont.Items; - - for (var j = 0; j < children.Count; ++j) - { - var child = children[j]; - - if (child.Decays && !child.IsLockedDown && !child.IsSecure && - child.LastMoved + child.DecayTime <= Core.Now) - { - Timer.StartTimer(child.Delete); - } - } - } - } } public override void Deserialize(IGenericReader reader) From 84153fba58b6f004cab5143fe28ea07e5e5ce448 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:43:44 -0700 Subject: [PATCH 02/21] fix: Fixes dirty-tracking gaps in generated content: setters, sub-object owners, BaseVendor (#2609) ## Why Delta world saves re-serialize an entity only when it has been marked dirty. An audit of the generated classes found three ways serialized state changes without a mark; this PR closes the ones that do not need a new generator package. ## What - **Custom `[SerializableProperty]` setters mark dirty.** Twelve hand-written setters assigned their backing field without `this.MarkDirty()`. `PlagueBeastLord.OpenedBy` was an auto-property carrying the attribute with no backing field at all; it is now a generated field with the same name, order and command-property exposure. - **Generated sub-objects are linked to their owner.** Without a `[DirtyTrackingEntity]` member the generator emits setters that mark nothing. Eight entity-owned sub-objects now carry the link and receive the owner through the constructor the generator calls: `BOBFilter` (owner is `IEntity`: a `PlayerMobile` or a `BulkOrderBook`), `BOBLargeSubEntry`, `PuzzleChestSolution` and `PuzzleChestSolutionAndTime`, `TalismanAttribute` (the random factories now take the talisman), `VendorItem`, `PlayerBBMessage`, `RaffleEntry`, `ShardPollOption`. Migration schemas were regenerated with the pinned tool; the only change is the value rule argument becoming `DeserializationRequiresParent`. - **BaseVendor uses the generator; restock amounts are no longer persisted.** The only state it wrote was which buy entries had grown restock amounts, packed by index into the live `SBInfos` tables. Restock is transient now and rebuilds on load; version 1 records are read and discarded through the legacy path. Every vendor subclass now serializes through a generated chain. ## Generator 4.1.0 This PR adopts SerializationGenerator 4.1.0 (modernuo/SerializationGenerator#55): SG3019/SG3020 diagnostics, `[VolatileSerializedState]`, `StopXxx()` timer helpers, and owner-constructor preference for sub-objects (so dictionary values are constructed with their owner; the 4.0.0 relink fallback is gone). `PlayerVendor.v3.json` gains `DeserializationRequiresParent` for its `VendorItem` values so the migration content struct constructs them with their vendor. SG3019 is an error under `TreatWarningsAsErrors` and generator diagnostics ignore pragmas, so the five contexts that live in whole-file player-keyed persistences (`ChampionTitle`, `ChampionTitleContext`, `MurderContext`, `VirtueContext`, `JailRecord`) now carry a `[DirtyTrackingEntity]` link to their `PlayerMobile`, which is where they will live once those blobs move onto the player record. `JailSystem.EmptyRecord` keeps a null player (`[CanBeNull]`). `ChampionTitle` needs both a context and a player constructor because the generator resolves the rule against the containing type but emits the call with the parent field; a comment in the file records this. ## Wire format Unchanged. No version bumps except `BaseVendor` 1 to 2 (which now writes nothing of its own). Schema diffs are rule-argument only (`DeserializationRequiresParent`). ## Testing Server.Tests 848 passed, UOContent.Tests 759 passed. --- .config/dotnet-tools.json | 2 +- Projects/Server/Server.csproj | 4 +- .../Engines/Bulk Orders/Books/BOBFilter.cs | 5 + .../Bulk Orders/Books/BOBLargeEntry.cs | 4 +- .../Bulk Orders/Books/BOBLargeSubEntry.cs | 10 +- .../Bulk Orders/Books/BulkOrderBook.cs | 4 +- .../Engines/CannedEvil/ChampionTitle.cs | 12 ++ .../CannedEvil/ChampionTitleContext.cs | 7 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 26 ++-- .../UOContent/Engines/Plants/PlantItem.cs | 1 + .../Player Murder System/MurderContext.cs | 1 + .../UOContent/Engines/Spawners/BaseSpawner.cs | 1 + .../Engines/Virtues/VirtueContext.cs | 7 ++ .../UOContent/Engines/Virtues/VirtueSystem.cs | 4 +- Projects/UOContent/Items/Armor/BaseArmor.cs | 3 + .../Fillable Containers/FillableContainer.cs | 1 + Projects/UOContent/Items/Food/Beverage.cs | 1 + .../Minor Artifacts/ML/BloodwoodSpirit.cs | 2 +- .../Items/Minor Artifacts/ML/TotemOfVoid.cs | 2 +- .../Items/Misc/PlayerBulletinBoards.cs | 16 +-- .../Skill Items/Magical/Misc/RecallRune.cs | 1 + .../Musical Instruments/BaseInstrument.cs | 2 + .../Special/House Raffle/HouseRaffleStone.cs | 13 +- .../Talismans/BaseTalisman.Migrations.cs | 6 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 48 ++++---- .../Items/Talismans/RandomTalisman.cs | 6 +- .../Items/Talismans/TalismanAttribute.cs | 14 ++- ...r.Engines.BulkOrders.BOBLargeEntry.v1.json | 2 +- ...r.Engines.BulkOrders.BulkOrderBook.v3.json | 2 +- ...es.CannedEvil.ChampionTitleContext.v1.json | 18 +-- .../Server.Items.BasePlayerBB.v0.json | 4 +- .../Server.Items.BaseTalisman.v1.json | 6 +- .../Server.Items.HouseRaffleStone.v4.json | 2 +- .../Server.Items.PuzzleChest.v1.json | 4 +- .../Server.Misc.ShardPoller.v1.json | 2 +- .../Server.Mobiles.BaseVendor.v2.json | 4 + .../Server.Mobiles.PlayerVendor.v3.json | 2 +- .../Server.Mobiles.PlayerVendor.v4.json | 2 +- Projects/UOContent/Misc/Loot.cs | 6 +- Projects/UOContent/Misc/ShardPoller.cs | 18 ++- .../Monsters/Misc/Melee/PlagueBeast.cs | 6 +- .../Monsters/Misc/Melee/PlagueBeastLord.cs | 6 +- Projects/UOContent/Mobiles/PlayerMobile.cs | 6 +- .../UOContent/Mobiles/Vendors/BaseVendor.cs | 111 +++--------------- .../UOContent/Mobiles/Vendors/PlayerVendor.cs | 4 +- .../UOContent/Mobiles/Vendors/VendorItem.cs | 12 +- .../Systems/JailSystem/JailRecord.cs | 7 ++ .../Systems/JailSystem/JailSystem.cs | 6 +- Projects/UOContent/UOContent.csproj | 4 +- 49 files changed, 223 insertions(+), 214 deletions(-) create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f9f5fbe15..3e22e2e66 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "4.0.0", + "version": "4.1.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index fd69557b7..ab982279f 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 f63e4f8d4..5b72a3c54 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -5,6 +5,11 @@ namespace Server.Engines.BulkOrders; [SerializationGenerator(2)] public partial class BOBFilter { + [DirtyTrackingEntity] + private IEntity _owner; + + public BOBFilter(IEntity owner) => _owner = owner; + [SerializableField(0)] [SaveFlag(nameof(ShouldSerializeType))] private int _type; diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs index 44f1e727c..b2d8873d7 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs @@ -26,7 +26,7 @@ public partial class BOBLargeEntry : BaseBOBEntry for (var i = 0; i < _entries.Length; ++i) { - _entries[i] = new BOBLargeSubEntry(bod.Entries[i]); + _entries[i] = new BOBLargeSubEntry(this, bod.Entries[i]); } } @@ -76,7 +76,7 @@ public partial class BOBLargeEntry : BaseBOBEntry for (var i = 0; i < Entries.Length; ++i) { - _entries[i] = new BOBLargeSubEntry(); + _entries[i] = new BOBLargeSubEntry(this); _entries[i].Deserialize(reader); } } diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs index 8a4a91637..8337dc91b 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs @@ -6,6 +6,9 @@ namespace Server.Engines.BulkOrders; [SerializationGenerator(0)] public partial class BOBLargeSubEntry { + [DirtyTrackingEntity] + private BOBLargeEntry _parent; + [SerializableField(0, setter: "private")] private Type _itemType; @@ -21,12 +24,11 @@ public partial class BOBLargeSubEntry [SerializableField(3, setter: "private")] private int _graphic; - public BOBLargeSubEntry() - { - } + public BOBLargeSubEntry(BOBLargeEntry parent) => _parent = parent; - public BOBLargeSubEntry(LargeBulkEntry lbe) + public BOBLargeSubEntry(BOBLargeEntry parent, LargeBulkEntry lbe) { + _parent = parent; _itemType = lbe.Details.Type; _amountCur = lbe.Amount; _number = lbe.Details.Number; diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs index a0c17c575..e4d788a7c 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs @@ -43,7 +43,7 @@ public partial class BulkOrderBook : Item, ISecurable LootType = LootType.Blessed; _entries = []; - _filter = new BOBFilter(); + _filter = new BOBFilter(this); _level = SecureLevel.CoOwners; } @@ -224,7 +224,7 @@ public partial class BulkOrderBook : Item, ISecurable _bookName = reader.ReadString(); - _filter = new BOBFilter(); + _filter = new BOBFilter(this); _filter.Deserialize(reader); var count = reader.ReadEncodedInt(); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs index c1970a449..dca633bb7 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs @@ -1,11 +1,23 @@ using System; using ModernUO.Serialization; +using Server.Mobiles; namespace Server.Engines.CannedEvil; [SerializationGenerator(0)] public partial class ChampionTitle { + [DirtyTrackingEntity] + private PlayerMobile _player; + + public ChampionTitle(ChampionTitleContext context) : this(context?.Player) + { + } + + // The generator resolves the deserialization constructor against the owning type, but emits the + // owner's own dirty-tracking reference (the player) at the call site, so both overloads exist. + public ChampionTitle(PlayerMobile player) => _player = player; + [EncodedInt] [SerializableField(0)] private int _value; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index 644e2b965..f610e485f 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -16,6 +16,7 @@ public partial class ChampionTitleContext [SerializedCommandProperty(AccessLevel.GameMaster)] private int _harrower; + [DirtyTrackingEntity] private PlayerMobile _player; public PlayerMobile Player => _player; @@ -45,7 +46,7 @@ public partial class ChampionTitleContext throw new NotImplementedException($"Cannot find ChampionSpawnType value {type}."); } - title = new ChampionTitle(); + title = new ChampionTitle(this); title.Deserialize(reader); } } @@ -290,7 +291,7 @@ public partial class ChampionTitleContext return null; } - return title ??= new ChampionTitle(); + return title ??= new ChampionTitle(this); } public void SetValue(ChampionSpawnType type, int value) @@ -313,7 +314,7 @@ public partial class ChampionTitleContext } else { - title = new ChampionTitle(); + title = new ChampionTitle(this); } title.Value = value; diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 53932c8f0..2da3b198b 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -23,11 +23,19 @@ namespace Server.Items [SerializationGenerator(1)] public partial class PuzzleChestSolution { + [DirtyTrackingEntity] + private PuzzleChest _chest; + [SerializableField(0)] private PuzzleChestCylinder[] _cylinders; public const int Length = 5; + // Declared first: the generator picks the first matching constructor, and a deserialized + // solution must know its chest to mark it dirty. + public PuzzleChestSolution(PuzzleChest chest) : this() => _chest = chest; + + // Transient solutions (player guesses being edited in a gump) have no owning chest. public PuzzleChestSolution() => _cylinders = [RandomCylinder(), RandomCylinder(), RandomCylinder(), RandomCylinder(), RandomCylinder()]; @@ -42,6 +50,9 @@ namespace Server.Items solution.Cylinders.AsSpan().CopyTo(Cylinders); } + protected PuzzleChestSolution(PuzzleChest chest, PuzzleChestSolution solution) : this(solution) => + _chest = chest; + private void Deserialize(IGenericReader reader, int version) { var length = reader.ReadEncodedInt(); @@ -174,10 +185,11 @@ namespace Server.Items [SerializableField(0)] private DateTime _when; - public PuzzleChestSolutionAndTime(DateTime when, PuzzleChestSolution solution) : base(solution) => _when = when; + public PuzzleChestSolutionAndTime(PuzzleChest chest, DateTime when, PuzzleChestSolution solution) + : base(chest, solution) => _when = when; - // For serialization - public PuzzleChestSolutionAndTime() + // The generator deserializes guesses through this constructor so each one knows its chest. + public PuzzleChestSolutionAndTime(PuzzleChest chest) : base(chest) { } } @@ -214,7 +226,7 @@ namespace Server.Items private void Deserialize(IGenericReader reader, int version) { - _solution = new PuzzleChestSolution(); + _solution = new PuzzleChestSolution(this); _solution.Deserialize(reader); var length = reader.ReadEncodedInt(); @@ -238,7 +250,7 @@ namespace Server.Items for (var i = 0; i < guessCount; i++) { var m = reader.ReadEntity(); - (_guesses[m] = new PuzzleChestSolutionAndTime()).Deserialize(reader); + (_guesses[m] = new PuzzleChestSolutionAndTime(this)).Deserialize(reader); } } @@ -329,7 +341,7 @@ namespace Server.Items } else { - (_guesses ??= []).Add(m, new PuzzleChestSolutionAndTime(Core.Now, solution)); + (_guesses ??= []).Add(m, new PuzzleChestSolutionAndTime(this, Core.Now, solution)); StartCleanupTimer(); m.SendGump(new StatusGump(correctCylinders, correctColors)); @@ -525,7 +537,7 @@ namespace Server.Items } } - Solution = new PuzzleChestSolution(); + Solution = new PuzzleChestSolution(this); } private void StartCleanupTimer() diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 5f66b1a7a..f829b0e20 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -96,6 +96,7 @@ public partial class PlantItem : Item, ISecurable var ratio = PlantSystem != null ? (double)PlantSystem.Hits / PlantSystem.MaxHits : 1.0; _plantStatus = value; + this.MarkDirty(); if (_plantStatus >= PlantStatus.DecorativePlant) { diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index 384c91c98..9be182300 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -58,6 +58,7 @@ public partial class MurderContext _lastMurderTime = Core.Now; } + [DirtyTrackingEntity] public PlayerMobile _player; public PlayerMobile Player => _player; diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 8efc54b8c..750a08257 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -308,6 +308,7 @@ public abstract partial class BaseSpawner : Item, ISpawner { _walkingRange = value; InvalidateProperties(); + this.MarkDirty(); } } diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 7524f77ed..e12d803f6 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -8,6 +8,13 @@ namespace Server.Engines.Virtues; [SerializationGenerator(1)] public partial class VirtueContext { + [DirtyTrackingEntity] + private PlayerMobile _player; + + public PlayerMobile Player => _player; + + public VirtueContext(PlayerMobile player) => _player = player; + private void MigrateFrom(V0Content content) { // Save-flagged values arrive as nullables; unset flags fall back to the same diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index e57a2d386..457899b91 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -99,7 +99,7 @@ public class VirtueSystem : GenericPersistence for (var i = 0; i < contextCount; i++) { var player = reader.ReadEntity(); - var virtues = new VirtueContext(); + var virtues = new VirtueContext(player); virtues.Deserialize(reader); if (player != null && virtues.IsUsed()) @@ -122,7 +122,7 @@ public class VirtueSystem : GenericPersistence ref var context = ref CollectionsMarshal.GetValueRefOrAddDefault(_playerVirtues, from, out var exists); if (!exists) { - context = new VirtueContext(); + context = new VirtueContext(from); } return context; diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index f4062326d..a55400e5a 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -195,6 +195,7 @@ namespace Server.Items { UnscaleDurability(); _quality = value; + this.MarkDirty(); ScaleDurability(); } } @@ -213,6 +214,7 @@ namespace Server.Items { UnscaleDurability(); _durability = value; + this.MarkDirty(); ScaleDurability(); } } @@ -246,6 +248,7 @@ namespace Server.Items UnscaleDurability(); _resource = value; + this.MarkDirty(); if (CraftItem.RetainsColor(GetType())) { diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index da4d8b77f..60ca33cb2 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -53,6 +53,7 @@ public abstract partial class FillableContainer : LockableContainer ClearContents(); _contentType = value; + this.MarkDirty(); Respawn(); } } diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 85eecf17f..85e27531e 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -350,6 +350,7 @@ public abstract partial class BaseBeverage : Item, IHasQuantity set { _quantity = Math.Clamp(value, 0, MaxQuantity); + this.MarkDirty(); InvalidateProperties(); diff --git a/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs b/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs index ad854cef6..4d6a70dd5 100644 --- a/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs +++ b/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs @@ -13,7 +13,7 @@ public partial class BloodwoodSpirit : BaseTalisman Removal = TalismanRemoval.Damage; Blessed = GetRandomBlessed(); - Protection = GetRandomProtection(false); + Protection = GetRandomProtection(this, false); SkillBonuses.SetValues(0, SkillName.SpiritSpeak, 10.0); SkillBonuses.SetValues(1, SkillName.Necromancy, 5.0); diff --git a/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs b/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs index 546f906c4..885130daf 100644 --- a/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs +++ b/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs @@ -14,7 +14,7 @@ public partial class TotemOfVoid : BaseTalisman MaxChargeTime = 1800; Blessed = GetRandomBlessed(); - Protection = GetRandomProtection(false); + Protection = GetRandomProtection(this, false); Attributes.RegenHits = 2; Attributes.LowerManaCost = 10; diff --git a/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs b/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs index 0dcd418aa..1c193c32d 100644 --- a/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs +++ b/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs @@ -74,13 +74,13 @@ public abstract partial class BasePlayerBB : Item, ISecurable if (_greeting != null) { - board.Greeting = new PlayerBBMessage(_greeting.Time, _greeting.Poster, _greeting.Message); + board.Greeting = new PlayerBBMessage(board, _greeting.Time, _greeting.Poster, _greeting.Message); } for (var i = 0; i < _messages.Count; i++) { var message = _messages[i]; - board.AddToMessages(new PlayerBBMessage(message.Time, message.Poster, message.Message)); + board.AddToMessages(new PlayerBBMessage(board, message.Time, message.Poster, message.Message)); } } @@ -176,7 +176,7 @@ public abstract partial class BasePlayerBB : Item, ISecurable if (text.Length > 0) { - var message = new PlayerBBMessage(Core.Now, from, text); + var message = new PlayerBBMessage(board, Core.Now, from, text); if (_greeting) { @@ -265,6 +265,9 @@ public abstract partial class BasePlayerBB : Item, ISecurable [SerializationGenerator(0)] public partial class PlayerBBMessage { + [DirtyTrackingEntity] + private BasePlayerBB _board; + [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _time; @@ -277,12 +280,11 @@ public partial class PlayerBBMessage [SerializedCommandProperty(AccessLevel.GameMaster)] private string _message; - public PlayerBBMessage() - { - } + public PlayerBBMessage(BasePlayerBB board) => _board = board; - public PlayerBBMessage(DateTime time, Mobile poster, string message) + public PlayerBBMessage(BasePlayerBB board, DateTime time, Mobile poster, string message) { + _board = board; _time = time; _poster = poster; _message = message; diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 14db7ac1e..1bb5a7f6e 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -42,6 +42,7 @@ public partial class RecallRune : Item set { _house = value; + this.MarkDirty(); CalculateHue(); InvalidateProperties(); } diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index f7716a4a1..5adff2ce7 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -92,6 +92,7 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer { UnscaleUses(); _quality = value; + this.MarkDirty(); InvalidateProperties(); ScaleUses(); } @@ -109,6 +110,7 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer set { _usesRemaining = value; + this.MarkDirty(); InvalidateProperties(); } } diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index 59e2b8277..21b0e5eb2 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -16,6 +16,9 @@ namespace Server.Items; [SerializationGenerator(0)] public partial class RaffleEntry { + [DirtyTrackingEntity] + private HouseRaffleStone _stone; + [SerializableField(0, setter: "private")] private Mobile _from; @@ -25,15 +28,17 @@ public partial class RaffleEntry [SerializableField(2, setter: "private")] private DateTime _date; - public RaffleEntry(Mobile from) + public RaffleEntry(HouseRaffleStone stone, Mobile from) { + _stone = stone; _from = from; _address = from?.NetState?.Address ?? IPAddress.None; _date = Core.Now; } - public RaffleEntry() + public RaffleEntry(HouseRaffleStone stone) { + _stone = stone; _from = null; _address = null; _date = Core.Now; @@ -454,7 +459,7 @@ public partial class HouseRaffleStone : Item if (_ticketPrice == 0 || from.Backpack?.ConsumeTotal(typeof(Gold), _ticketPrice) == true || Banker.Withdraw(from, _ticketPrice)) { - AddToEntries(new RaffleEntry(from)); + AddToEntries(new RaffleEntry(this, from)); from.SendMessage(MessageHue, "You have successfully entered the plot's raffle."); } @@ -539,7 +544,7 @@ public partial class HouseRaffleStone : Item for (var i = 0; i < entryCount; i++) { - var entry = new RaffleEntry(); + var entry = new RaffleEntry(this); entry.Deserialize(reader); if (entry.From == null) diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs index 85d41c852..c2da33683 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs @@ -26,19 +26,19 @@ public partial class BaseTalisman BlessedFor = reader.ReadEntity(); } - _protection = new TalismanAttribute(); + _protection = new TalismanAttribute(this); if (GetOldSaveFlag(flags, OldSaveFlag.Protection)) { _protection.Deserialize(reader); } - _killer = new TalismanAttribute(); + _killer = new TalismanAttribute(this); if (GetOldSaveFlag(flags, OldSaveFlag.Killer)) { _killer.Deserialize(reader); } - _summoner = new TalismanAttribute(); + _summoner = new TalismanAttribute(this); if (GetOldSaveFlag(flags, OldSaveFlag.Summoner)) { _summoner.Deserialize(reader); diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index b3f0c2ed4..f7b118df6 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -155,7 +155,7 @@ public partial class BaseTalisman : Item, IAosItem public bool ShouldSerializeProtection() => !_protection.IsEmpty; - private TalismanAttribute ProtectionDefaultValue() => new(); + private TalismanAttribute ProtectionDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] @@ -166,7 +166,7 @@ public partial class BaseTalisman : Item, IAosItem public bool ShouldSerializeKiller() => !_killer.IsEmpty; - private TalismanAttribute KillerDefaultValue() => new(); + private TalismanAttribute KillerDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] @@ -177,7 +177,7 @@ public partial class BaseTalisman : Item, IAosItem public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; - private TalismanAttribute SummonerDefaultValue() => new(); + private TalismanAttribute SummonerDefaultValue() => new(this); [InvalidateProperties] [SerializableField(5)] @@ -267,9 +267,9 @@ public partial class BaseTalisman : Item, IAosItem { Layer = Layer.Talisman; - _protection = new TalismanAttribute(); - _killer = new TalismanAttribute(); - _summoner = new TalismanAttribute(); + _protection = new TalismanAttribute(this); + _killer = new TalismanAttribute(this); + _summoner = new TalismanAttribute(this); Attributes = new AosAttributes(this); SkillBonuses = new AosSkillBonuses(this); } @@ -319,9 +319,9 @@ public partial class BaseTalisman : Item, IAosItem return; } - talisman._summoner = new TalismanAttribute(_summoner); - talisman._protection = new TalismanAttribute(_protection); - talisman._killer = new TalismanAttribute(_killer); + talisman._summoner = new TalismanAttribute(talisman, _summoner); + talisman._protection = new TalismanAttribute(talisman, _protection); + talisman._killer = new TalismanAttribute(talisman, _killer); talisman.Attributes = new AosAttributes(newItem, Attributes); talisman.SkillBonuses = new AosSkillBonuses(newItem, SkillBonuses); } @@ -660,17 +660,17 @@ public partial class BaseTalisman : Item, IAosItem public virtual void SetSummoner(Type type, TextDefinition name) { - _summoner = new TalismanAttribute(type, name); + _summoner = new TalismanAttribute(this, type, name); } public virtual void SetProtection(Type type, TextDefinition name, int amount) { - _protection = new TalismanAttribute(type, name, amount); + _protection = new TalismanAttribute(this, type, name, amount); } public virtual void SetKiller(Type type, TextDefinition name, int amount) { - _killer = new TalismanAttribute(type, name, amount); + _killer = new TalismanAttribute(this, type, name, amount); } public virtual void StartTimer() @@ -712,18 +712,18 @@ public partial class BaseTalisman : Item, IAosItem public static Type GetRandomSummonType() => _summons.RandomElement(); - public static TalismanAttribute GetRandomSummoner() + public static TalismanAttribute GetRandomSummoner(BaseTalisman owner) { if (Utility.RandomDouble() < 0.975) { - return new TalismanAttribute(); + return new TalismanAttribute(owner); } var num = Utility.Random(_summons.Length); return num > 14 - ? new TalismanAttribute(_summons[num], _summonLabels[num], 10) - : new TalismanAttribute(_summons[num], _summonLabels[num]); + ? new TalismanAttribute(owner, _summons[num], _summonLabels[num], 10) + : new TalismanAttribute(owner, _summons[num], _summonLabels[num]); } public static TalismanRemoval GetRandomRemoval() @@ -736,32 +736,32 @@ public partial class BaseTalisman : Item, IAosItem return TalismanRemoval.None; } - public static TalismanAttribute GetRandomKiller() => GetRandomKiller(true); + public static TalismanAttribute GetRandomKiller(BaseTalisman owner) => GetRandomKiller(owner, true); - public static TalismanAttribute GetRandomKiller(bool includingNone) + public static TalismanAttribute GetRandomKiller(BaseTalisman owner, bool includingNone) { if (includingNone && Utility.RandomBool()) { - return new TalismanAttribute(); + return new TalismanAttribute(owner); } var num = Utility.Random(_killers.Length); - return new TalismanAttribute(_killers[num], _killerLabels[num], Utility.RandomMinMax(10, 100)); + return new TalismanAttribute(owner, _killers[num], _killerLabels[num], Utility.RandomMinMax(10, 100)); } - public static TalismanAttribute GetRandomProtection() => GetRandomProtection(true); + public static TalismanAttribute GetRandomProtection(BaseTalisman owner) => GetRandomProtection(owner, true); - public static TalismanAttribute GetRandomProtection(bool includingNone) + public static TalismanAttribute GetRandomProtection(BaseTalisman owner, bool includingNone) { if (includingNone && Utility.RandomBool()) { - return new TalismanAttribute(); + return new TalismanAttribute(owner); } var num = Utility.Random(_killers.Length); - return new TalismanAttribute(_killers[num], _killerLabels[num], Utility.RandomMinMax(5, 60)); + return new TalismanAttribute(owner, _killers[num], _killerLabels[num], Utility.RandomMinMax(5, 60)); } public static SkillName GetRandomSkill() => _skills.RandomElement(); diff --git a/Projects/UOContent/Items/Talismans/RandomTalisman.cs b/Projects/UOContent/Items/Talismans/RandomTalisman.cs index 5235c6f20..60529c203 100644 --- a/Projects/UOContent/Items/Talismans/RandomTalisman.cs +++ b/Projects/UOContent/Items/Talismans/RandomTalisman.cs @@ -8,7 +8,7 @@ public partial class RandomTalisman : BaseTalisman [Constructible] public RandomTalisman() : base(GetRandomItemID()) { - Summoner = GetRandomSummoner(); + Summoner = GetRandomSummoner(this); if (Summoner.IsEmpty) { @@ -36,8 +36,8 @@ public partial class RandomTalisman : BaseTalisman Blessed = GetRandomBlessed(); Slayer = GetRandomSlayer(); - Protection = GetRandomProtection(); - Killer = GetRandomKiller(); + Protection = GetRandomProtection(this); + Killer = GetRandomKiller(this); Skill = GetRandomSkill(); ExceptionalBonus = GetRandomExceptional(); SuccessBonus = GetRandomSuccessful(); diff --git a/Projects/UOContent/Items/Talismans/TalismanAttribute.cs b/Projects/UOContent/Items/Talismans/TalismanAttribute.cs index e1a7ad6ec..153951081 100644 --- a/Projects/UOContent/Items/Talismans/TalismanAttribute.cs +++ b/Projects/UOContent/Items/Talismans/TalismanAttribute.cs @@ -7,6 +7,9 @@ namespace Server.Items; [SerializationGenerator(1, false)] public partial class TalismanAttribute { + [DirtyTrackingEntity] + private BaseTalisman _owner; + [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Type _type; @@ -19,12 +22,12 @@ public partial class TalismanAttribute [SerializedCommandProperty(AccessLevel.GameMaster)] private int _amount; - public TalismanAttribute() : this(null, null) - { - } + public TalismanAttribute(BaseTalisman owner) => _owner = owner; - public TalismanAttribute(TalismanAttribute copy) + public TalismanAttribute(BaseTalisman owner, TalismanAttribute copy) { + _owner = owner; + if (copy != null) { _type = copy.Type; @@ -33,8 +36,9 @@ public partial class TalismanAttribute } } - public TalismanAttribute(Type type, TextDefinition name, int amount = 0) + public TalismanAttribute(BaseTalisman owner, Type type, TextDefinition name, int amount = 0) { + _owner = owner; _type = type; _name = name; _amount = amount; diff --git a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json index ec12a5845..43bc92702 100644 --- a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json +++ b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json @@ -9,7 +9,7 @@ "ruleArguments": [ "Server.Engines.BulkOrders.BOBLargeSubEntry", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json index 37899091a..74f878450 100644 --- a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json +++ b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json @@ -28,7 +28,7 @@ "type": "Server.Engines.BulkOrders.BOBFilter", "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json index af6658d06..c2811c097 100644 --- a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json @@ -16,7 +16,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -25,7 +25,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -34,7 +34,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -43,7 +43,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -52,7 +52,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -61,7 +61,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -70,7 +70,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -79,7 +79,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -88,7 +88,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json b/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json index 2d19ebd1a..09f2db9d1 100644 --- a/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json +++ b/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json @@ -20,7 +20,7 @@ "type": "Server.Items.PlayerBBMessage", "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "", + "DeserializationRequiresParent", "@CanBeNull" ] }, @@ -31,7 +31,7 @@ "ruleArguments": [ "Server.Items.PlayerBBMessage", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json b/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json index 958652db9..2d997d4a5 100644 --- a/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json +++ b/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json @@ -26,7 +26,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -35,7 +35,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -44,7 +44,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { diff --git a/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json b/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json index b2d79f227..351cb26dc 100644 --- a/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json +++ b/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json @@ -66,7 +66,7 @@ "ruleArguments": [ "Server.Items.RaffleEntry", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json index 508755f69..fb3360ba2 100644 --- a/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json +++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json @@ -7,7 +7,7 @@ "type": "Server.Items.PuzzleChestSolution", "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -32,7 +32,7 @@ "Server.Items.PuzzleChestSolutionAndTime", "RawSerializableMigrationRule", "2", - "", + "DeserializationRequiresParent", "@CanBeNull" ] } diff --git a/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json b/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json index 46345e368..e8611014a 100644 --- a/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json +++ b/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json @@ -38,7 +38,7 @@ "ruleArguments": [ "Server.Misc.ShardPollOption", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json new file mode 100644 index 000000000..887fd9bdf --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "type": "Server.Mobiles.BaseVendor" +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json index aee2b1da6..407b93cfc 100644 --- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json @@ -55,7 +55,7 @@ "Server.Mobiles.VendorItem", "RawSerializableMigrationRule", "1", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json index 6b9c9eb1c..a2c354874 100644 --- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json @@ -55,7 +55,7 @@ "Server.Mobiles.VendorItem", "RawSerializableMigrationRule", "1", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Misc/Loot.cs b/Projects/UOContent/Misc/Loot.cs index 2477c25b5..808364298 100644 --- a/Projects/UOContent/Misc/Loot.cs +++ b/Projects/UOContent/Misc/Loot.cs @@ -738,7 +738,7 @@ namespace Server { var talisman = new BaseTalisman(BaseTalisman.GetRandomItemID()); - talisman.Summoner = BaseTalisman.GetRandomSummoner(); + talisman.Summoner = BaseTalisman.GetRandomSummoner(talisman); if (talisman.Summoner.IsEmpty) { @@ -758,8 +758,8 @@ namespace Server talisman.Blessed = BaseTalisman.GetRandomBlessed(); talisman.Slayer = BaseTalisman.GetRandomSlayer(); - talisman.Protection = BaseTalisman.GetRandomProtection(); - talisman.Killer = BaseTalisman.GetRandomKiller(); + talisman.Protection = BaseTalisman.GetRandomProtection(talisman); + talisman.Killer = BaseTalisman.GetRandomKiller(talisman); talisman.Skill = BaseTalisman.GetRandomSkill(); talisman.ExceptionalBonus = BaseTalisman.GetRandomExceptional(); talisman.SuccessBonus = BaseTalisman.GetRandomSuccessful(); diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 8516e6678..643657c1f 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -187,7 +187,7 @@ public partial class ShardPoller : Item for (var i = 0; i < _options.Length; ++i) { - var option = _options[i] = new ShardPollOption(); + var option = _options[i] = new ShardPollOption(this); option.Deserialize(reader); } } @@ -212,15 +212,23 @@ public partial class ShardPoller : Item [SerializationGenerator(1, false)] public partial class ShardPollOption { + [DirtyTrackingEntity] + private ShardPoller _poller; + private int _lineBreaks = -1; [SerializableField(1)] private IPAddress[] _voters; - public ShardPollOption() => _voters = []; - - public ShardPollOption(string title) + public ShardPollOption(ShardPoller poller) { + _poller = poller; + _voters = []; + } + + public ShardPollOption(ShardPoller poller, string title) + { + _poller = poller; _title = title; _voters = []; } @@ -600,7 +608,7 @@ public partial class ShardPollPrompt : Prompt if (_option == null) { - _poller.AddOption(new ShardPollOption(text)); + _poller.AddOption(new ShardPollOption(_poller, text)); } else { diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs index 8c7984360..05fc563b7 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs @@ -21,7 +21,11 @@ namespace Server.Mobiles public int DevourGoal { get => IsParagon ? _devourGoal + 25 : _devourGoal; - set => _devourGoal = value; + set + { + _devourGoal = value; + this.MarkDirty(); + } } [Constructible] diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs index bbcef613e..0fd0c5bb3 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs @@ -52,9 +52,9 @@ namespace Server.Mobiles VirtualArmor = 50; } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(3)] - public Mobile OpenedBy { get; set; } + [SerializedCommandProperty(AccessLevel.GameMaster)] + [SerializableField(3)] + private Mobile _openedBy; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 83c0e4cca..d0b1b2c7a 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -199,7 +199,7 @@ namespace Server.Mobiles VisibilityList = new List(); PermaFlags = new List(); - BOBFilter = new BOBFilter(); + BOBFilter = new BOBFilter(this); m_GameTime = TimeSpan.Zero; m_GuildRank = RankDefinition.Lowest; @@ -2958,7 +2958,7 @@ namespace Server.Mobiles case 13: // just removed m_PaidInsurance list case 12: { - BOBFilter = new BOBFilter(); + BOBFilter = new BOBFilter(this); BOBFilter.Deserialize(reader); goto case 11; } @@ -3081,7 +3081,7 @@ namespace Server.Mobiles } PermaFlags ??= new List(); - BOBFilter ??= new BOBFilter(); + BOBFilter ??= new BOBFilter(this); // Default to member if going from older version to new version (only time it should be null) m_GuildRank ??= RankDefinition.Member; diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index 485d56c1d..135dd5ca4 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; using Server.Engines.BulkOrders; @@ -24,7 +25,8 @@ namespace Server.Mobiles ThighBoots } - public abstract class BaseVendor : BaseCreature, IVendor + [SerializationGenerator(2, false)] + public abstract partial class BaseVendor : BaseCreature, IVendor { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseVendor)); private const int MaxSell = 500; @@ -1297,109 +1299,28 @@ namespace Server.Mobiles Region.GetRegion()?.CheckVendorAccess(this, from) != false || Region != from.Region && from.Region.GetRegion()?.CheckVendorAccess(this, from) != false; - public override void Serialize(IGenericWriter writer) + [AfterDeserialization] + private void AfterDeserialization() { - base.Serialize(writer); - - writer.Write(1); // version - - var sbInfos = SBInfos; - - for (var i = 0; i < sbInfos?.Count; ++i) - { - var sbInfo = sbInfos[i]; - var buyInfo = sbInfo.BuyInfo; - - for (var j = 0; j < buyInfo?.Count; ++j) - { - var gbi = buyInfo[j]; - - var maxAmount = gbi.MaxAmount; - - var doubled = maxAmount switch - { - 40 => 1, - 80 => 2, - 160 => 3, - 320 => 4, - 640 => 5, - 999 => 6, - _ => 0 - }; - - if (doubled > 0) - { - writer.WriteEncodedInt(1 + j * sbInfos.Count + i); - writer.WriteEncodedInt(doubled); - } - } - } - - writer.WriteEncodedInt(0); - } - - public override void Deserialize(IGenericReader reader) - { - base.Deserialize(reader); - - var version = reader.ReadInt(); - LoadSBInfo(); - var sbInfos = SBInfos; - - switch (version) - { - case 1: - { - int index; - - while ((index = reader.ReadEncodedInt()) > 0) - { - var doubled = reader.ReadEncodedInt(); - - if (sbInfos != null) - { - index -= 1; - var sbInfoIndex = index % sbInfos.Count; - var buyInfoIndex = index / sbInfos.Count; - - if (sbInfoIndex >= 0 && sbInfoIndex < sbInfos.Count) - { - var sbInfo = sbInfos[sbInfoIndex]; - var buyInfo = sbInfo.BuyInfo; - - if (buyInfo != null && buyInfoIndex >= 0 && buyInfoIndex < buyInfo.Count) - { - var gbi = buyInfo[buyInfoIndex]; - - var amount = doubled switch - { - 1 => 40, - 2 => 80, - 3 => 160, - 4 => 320, - 5 => 640, - 6 => 999, - _ => 20 - }; - - gbi.Amount = gbi.MaxAmount = amount; - } - } - } - } - - break; - } - } - if (IsParagon) { IsParagon = false; } } + // Version 1 persisted which buy entries had grown restock amounts, packed by index into + // the live SBInfos tables. Restock is transient now: it rebuilds from SBInfos on load, so + // the pairs are read and discarded. + private void Deserialize(IGenericReader reader, int version) + { + while (reader.ReadEncodedInt() > 0) + { + reader.ReadEncodedInt(); + } + } + public override void AddCustomContextEntries(Mobile from, ref PooledRefList list) { if (from.Alive && IsActiveVendor) diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 9b8f5a2f3..6dc44c973 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -188,7 +188,7 @@ public partial class PlayerVendor : Mobile for (var i = 0; i < count; i++) { var item = reader.ReadEntity(); - var vi = new VendorItem(); + var vi = new VendorItem(this); vi.Deserialize(reader); _sellItems[item] = vi; } @@ -447,7 +447,7 @@ public partial class PlayerVendor : Mobile { RemoveVendorItem(item); - var vi = new VendorItem(item, price, description, created); + var vi = new VendorItem(this, item, price, description, created); ReplaceInSellItems(item, vi); item.InvalidateProperties(); diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 99d1446e6..4c1e12467 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -7,6 +7,9 @@ namespace Server.Mobiles; [SerializationGenerator(0, false)] public partial class VendorItem { + [DirtyTrackingEntity] + private PlayerVendor _vendor; + [SerializableField(0)] private Item _item; @@ -16,12 +19,13 @@ public partial class VendorItem [SerializableField(3)] private DateTime _created; - public VendorItem() - { - } + // The generator deserializes dictionary values through this constructor so every entry + // knows its vendor. + public VendorItem(PlayerVendor vendor) => _vendor = vendor; - public VendorItem(Item item, int price, string description, DateTime created) + public VendorItem(PlayerVendor vendor, Item item, int price, string description, DateTime created) { + _vendor = vendor; _item = item; _price = price; _description = description ?? ""; diff --git a/Projects/UOContent/Systems/JailSystem/JailRecord.cs b/Projects/UOContent/Systems/JailSystem/JailRecord.cs index c57ef8622..2cec17d55 100644 --- a/Projects/UOContent/Systems/JailSystem/JailRecord.cs +++ b/Projects/UOContent/Systems/JailSystem/JailRecord.cs @@ -1,11 +1,18 @@ using System; using ModernUO.Serialization; +using Server.Mobiles; namespace Server.Systems.JailSystem; [SerializationGenerator(0)] public partial class JailRecord { + [DirtyTrackingEntity] + [CanBeNull] + private PlayerMobile _player; + + public JailRecord(PlayerMobile player) => _player = player; + [SerializableField(0)] private int _jailCount; diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs index c79c44303..8802d0487 100644 --- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -42,7 +42,7 @@ public class JailSystem : GenericPersistence // Jail map, change this for custom maps public static readonly Map JailMap = Map.Felucca; - private static readonly JailRecord EmptyRecord = new(); + private static readonly JailRecord EmptyRecord = new(null); private static readonly HashSet CurrentlyBeingJailed = []; private static readonly Dictionary PlayerJailRecords = []; @@ -96,7 +96,7 @@ public class JailSystem : GenericPersistence if (!PlayerJailRecords.TryGetValue(player, out var record)) { - PlayerJailRecords[player] = record = new JailRecord(); + PlayerJailRecords[player] = record = new JailRecord(player); } record.JailCount++; @@ -409,7 +409,7 @@ public class JailSystem : GenericPersistence for (var i = 0; i < count; i++) { var player = reader.ReadEntity(); - var record = new JailRecord(); + var record = new JailRecord(player); record.Deserialize(reader); if (player != null) diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 60f28ac6f..40919e197 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -50,8 +50,8 @@ - - + + From 003491472f08520bf9905abe54d715b894545902 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:46:14 -0700 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20world=20save=20failure=20handling?= =?UTF-8?q?=20=E2=80=94=20no=20partial=20snapshots,=20no=20worker=20crash,?= =?UTF-8?q?=20staged=20publication=20(#2612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-existing world-save failure-handling bugs, found while reviewing the delta-saves engine (#2610), split out so they land and get adopted on their own before the larger change. #2610 will be rebased on top once this merges. Tracked in project Delta Saves (#7). ## 1. A snapshot missing a segment was published `GenericEntityPersistence.WriteSnapshot` logged a segment (or self-payload) write error and carried on. The save was published without those records, and every entity in the failed segment was deleted at the next load. The error now propagates: `WriteFiles` never moves a partial snapshot over `Saves/`, the previous save stays authoritative, and staff are told. ## 2. A serializer exception killed the process, or published a partial freeze An entity whose `Serialize` threw on a worker thread was an unhandled exception on that thread, which terminates the process. On the inline (main-thread) drain it was caught, but the snapshot was still queued for writing with whatever the workers had produced. Workers now record the first exception and keep draining so the queue empties and the wake/pause handshake completes; after every worker paused, `Snapshot` treats a recorded error (or a failure of the drain itself) as a failed save: nothing is written, the previous save stays, staff are told, and the world resumes. A `WorldSave` handler throwing is logged but does not invalidate the serialized snapshot. ## 3. Publication was not transactional Publishing moved the previous `Saves/` away (`AutoArchive` in `WorldSavePostSnapshot`) and only then moved the new files in. A subscriber throwing after the archive, or the final move failing, left nothing at `Saves/` until the next save; a crash in that window lost the newest save at the next boot. The snapshot now moves to `Saves.next` as soon as its files are complete; only then do subscribers archive the previous save (same event, same `OldSavePath`, so `AutoArchive` and shard subscribers are unchanged), and the staged directory is renamed into place, which is atomic on one volume (file-by-file move across volumes). A staged directory is by construction a complete save newer than `Saves/`, so an interrupted publish is finished at the next boot (before load) or before the next save: whatever sits at `Saves/` is set aside as `Saves.previous-` (never deleted) and the staged save is published, with a warning naming the directory to archive or delete by hand. ## Tests Server.Tests 860 / UOContent.Tests green. New: a throwing serializer is recorded on the worker and the next drain starts clean; a segment that cannot be indexed fails `WriteSnapshot` instead of dropping records; staged-save recovery with and without an existing `Saves/`, and as a no-op. --- .../GenericEntityPersistenceRoundTripTests.cs | 150 +++++++++++++++++- .../Serialization/StagedSavePublishTests.cs | 83 ++++++++++ .../Serialization/GenericEntityPersistence.cs | 7 +- .../SerializationThreadWorker.cs | 27 ++++ Projects/Server/World/World.cs | 144 +++++++++++++++-- 5 files changed, 390 insertions(+), 21 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs index f2114a427..48ba16e55 100644 --- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs @@ -20,7 +20,7 @@ internal class RoundTripEntity : ISerializable { } - public void Serialize(IGenericWriter writer) + public virtual void Serialize(IGenericWriter writer) { writer.Write(Value); writer.Write(Name); @@ -33,6 +33,15 @@ internal class RoundTripEntity : ISerializable } } +internal class ThrowingRoundTripEntity : RoundTripEntity +{ + public ThrowingRoundTripEntity(Serial serial) : base(serial) + { + } + + public override void Serialize(IGenericWriter writer) => throw new InvalidOperationException("broken serializer"); +} + [Collection("Sequential Server Tests")] public class GenericEntityPersistenceRoundTripTests { @@ -161,4 +170,143 @@ public class GenericEntityPersistenceRoundTripTests Directory.Delete(dir, true); } } + + /// + /// A serializer throwing on a worker used to be an unhandled exception on that thread. + /// The worker records it, finishes the drain so the handshake completes, and the loop + /// fails the save. + /// + [Fact] + public void SerializerException_IsRecordedOnTheWorker_AndTheDrainCompletes() + { + 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 persistence = new RoundTripPersistence(2002); + + try + { + for (var i = 1; i <= 100; i++) + { + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = i == 50 + ? new ThrowingRoundTripEntity(serial) + : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; + } + + 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(); + } + + Exception error = null; + foreach (var worker in workers) + { + error ??= worker.Error; + } + + Assert.IsType(error); + persistence.PostWorldSave(); + + // The next drain starts clean. + foreach (var worker in workers) + { + worker.Wake(); + } + + foreach (var worker in workers) + { + worker.Sleep(); + Assert.Null(worker.Error); + } + } + finally + { + persistence.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + } + } + + /// + /// A segment that cannot be written used to be logged and dropped, publishing a save + /// without those entities (the loader then deletes them). It now fails the save. + /// + [Fact] + public void WriteSnapshot_FailsTheSave_InsteadOfDroppingASegment() + { + 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 persistence = new RoundTripPersistence(2003); + var dir = Path.Combine(Path.GetTempPath(), $"muo-segmentfail-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + try + { + for (var i = 1; i <= 100; i++) + { + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; + } + + // Deliberately not registered: the writer cannot index the type. + + 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(); + } + + Assert.Throws(() => persistence.WriteSnapshot(dir)); + persistence.PostWorldSave(); + } + finally + { + persistence.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + + World._threadWorkers = previousWorkers; + Directory.Delete(dir, true); + } + } } diff --git a/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs b/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs new file mode 100644 index 000000000..2b1b3aff0 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using Xunit; + +namespace Server.Tests; + +/// +/// The publish protocol: a complete snapshot is staged next to Saves/ before the previous +/// save is touched, and an interrupted publish is finished at the next boot or save. +/// +[Collection("Sequential Server Tests")] +public class StagedSavePublishTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"muo-staged-{Guid.NewGuid():N}"); + private readonly string _previousSavePath; + + public StagedSavePublishTests() + { + Directory.CreateDirectory(_root); + _previousSavePath = World.SavePath; + World.SetSavePathForTest(Path.Combine(_root, "Saves")); + } + + public void Dispose() + { + World.SetSavePathForTest(_previousSavePath); + + try + { + Directory.Delete(_root, true); + } + catch + { + // best effort + } + } + + private static void WriteMarker(string dir, string name) + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "marker.txt"), name); + } + + private static string ReadMarker(string dir) => File.ReadAllText(Path.Combine(dir, "marker.txt")); + + [Fact] + public void NothingStaged_RecoveryIsANoOp() + { + WriteMarker(World.SavePath, "current"); + + World.RecoverStagedSave(); + + Assert.Equal("current", ReadMarker(World.SavePath)); + Assert.Single(Directory.GetDirectories(_root)); + } + + [Fact] + public void StagedSave_ReplacesSaves_AndKeepsThePreviousOne() + { + WriteMarker(World.SavePath, "old"); + WriteMarker(World.StagedSavePath, "new"); + + World.RecoverStagedSave(); + + Assert.Equal("new", ReadMarker(World.SavePath)); + Assert.False(Directory.Exists(World.StagedSavePath)); + + var aside = Array.FindAll(Directory.GetDirectories(_root), d => d.Contains(".previous-")); + Assert.Single(aside); + Assert.Equal("old", ReadMarker(aside[0])); + } + + [Fact] + public void StagedSave_WithNoSaves_IsPublished() + { + WriteMarker(World.StagedSavePath, "new"); + + World.RecoverStagedSave(); + + Assert.Equal("new", ReadMarker(World.SavePath)); + Assert.Single(Directory.GetDirectories(_root)); + } +} diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 9a1490504..70e9c6aab 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -158,6 +158,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer _selfPosition, _selfLength ); + throw; } binPosition += _selfLength; @@ -205,6 +206,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer } catch (Exception error) { + // Never publish a partial snapshot: entities missing from the idx are deleted on load. logger.Error( error, "Error writing segment: (Thread: {Thread} - {Start}, {Records} records)", @@ -212,6 +214,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer segment.HeapStart, segment.RecordCount ); + throw; } } } @@ -318,9 +321,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private ushort GetTypeIndex(T entity) { // Every path into EntitiesBySerial registers the type first, so this cannot fire. - // If it ever does, the segment-level catch in WriteSnapshot logs it and moves on — - // the failed segment's records are dropped from the idx while binPosition rewinds, - // so treat any occurrence as a serious bug in an insertion path, not a bad entity. + // If it does, the save fails; treat it as a bug in an insertion path, not a bad entity. if (!_typeIndexes.TryGetValue(entity.GetType(), out var typeIndex)) { throw new InvalidOperationException( diff --git a/Projects/Server/Serialization/SerializationThreadWorker.cs b/Projects/Server/Serialization/SerializationThreadWorker.cs index 5a9b5ed1c..74973a80c 100644 --- a/Projects/Server/Serialization/SerializationThreadWorker.cs +++ b/Projects/Server/Serialization/SerializationThreadWorker.cs @@ -78,6 +78,12 @@ public class SerializationThreadWorker internal List Lengths => _lengths; internal List BufferEntities => _bufferEntities; + /// + /// First serializer exception during the drain. The drain continues so the handshake + /// completes; the loop fails the save once every worker has paused. + /// + public Exception Error { get; private set; } + /// /// Releases the write logs after the snapshot is written so serialized entity /// references don't linger between saves. Capacity is retained: the logs regrow to @@ -154,6 +160,25 @@ public class SerializationThreadWorker public ReadOnlySpan GetHeap(int start, int length) => _heap.AsSpan(start, length); private long ProcessChunk(in SerializationChunkSource.Chunk chunk, BufferWriter writer) + { + try + { + return ProcessChunkCore(in chunk, writer); + } + catch (Exception ex) + { + Error ??= ex; + + if (chunk.Buffer != null) + { + _chunkSource.Return(chunk.Buffer, chunk.Count); + } + + return 0; + } + } + + private long ProcessChunkCore(in SerializationChunkSource.Chunk chunk, BufferWriter writer) { if (chunk.Single != null) { @@ -213,6 +238,7 @@ public class SerializationThreadWorker public void DrainInline() { ReleaseWriteLogs(); + Error = null; var writer = new BufferWriter(_heap, true); var entities = 0L; @@ -238,6 +264,7 @@ public class SerializationThreadWorker while (worker._startEvent.WaitOne()) { worker.ReleaseWriteLogs(); + worker.Error = null; var writer = new BufferWriter(worker._heap, true); var entities = 0L; diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index c00fc85f6..7ce1b52f2 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -122,6 +122,8 @@ public static class World UseMultiThreadedSaves = ServerConfiguration.GetOrUpdateSetting("world.useMultithreadedSaves", true); } + internal static void SetSavePathForTest(string path) => SavePath = path; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void WaitForWriteCompletion() => _diskWriteHandle.WaitOne(); @@ -195,6 +197,8 @@ public static class World logger.Information("Loading world"); var watch = Stopwatch.StartNew(); + RecoverStagedSave(); + Persistence.Load(SavePath); EventSink.InvokeWorldLoad(); @@ -271,6 +275,8 @@ public static class World { try { + RecoverStagedSave(); + // Allocate the heaps for the GC foreach (var worker in _threadWorkers) { @@ -322,22 +328,49 @@ public static class World } Persistence.SerializeAll(); - PauseSerializationThreads(); - LogWorkerBalance(); - - EventSink.InvokeWorldSave(); } catch (Exception ex) { exception = ex; } - WorldState = WorldState.WritingSave; - ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath); + // Always join the workers; any serializer exception fails the save. + try + { + PauseSerializationThreads(); + } + catch (Exception ex) + { + exception ??= ex; + } + + for (var i = 0; i < _threadWorkers.Length; i++) + { + exception ??= _threadWorkers[i].Error; + } + + if (exception == null) + { + LogWorkerBalance(); + + try + { + EventSink.InvokeWorldSave(); + } + catch (Exception ex) + { + logger.Error(ex, "A WorldSave handler failed"); + Persistence.TraceException(ex); + } + } + watch.Stop(); if (exception == null) { + WorldState = WorldState.WritingSave; + ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath); + var duration = watch.Elapsed.TotalSeconds; logger.Information("Saving world {Status} ({Duration:F2} seconds)", "done", duration); @@ -349,6 +382,9 @@ public static class World Persistence.TraceException(exception); BroadcastStaff(0x35, true, "World save failed! Check the logs!"); + + _diskWriteHandle.Set(); + FinishWorldSave(); } } @@ -361,17 +397,7 @@ public static class World logger.Information("Writing world save snapshot"); Persistence.WriteSnapshotAll(snapshotPath); - - try - { - EventSink.InvokeWorldSavePostSnapshot(SavePath, snapshotPath); - PathUtility.MoveDirectoryContents(snapshotPath, SavePath); - Directory.SetLastWriteTimeUtc(SavePath, Core.Now); - } - catch (Exception ex) - { - Persistence.TraceException(ex); - } + PublishSnapshot(snapshotPath); watch.Stop(); logger.Information("Writing world save snapshot {Status} ({Duration:F2} seconds)", "done", watch.Elapsed.TotalSeconds); @@ -388,6 +414,90 @@ public static class World Core.LoopContext.Post(FinishWorldSave); } + /// + /// A complete snapshot is staged here before the previous save is touched; a staged + /// directory is always a complete save newer than . + /// + internal static string StagedSavePath => SavePath + ".next"; + + // Stage, let subscribers archive the previous save, then rename the staged save into place. + private static void PublishSnapshot(string snapshotPath) + { + var staging = StagedSavePath; + + if (Directory.Exists(staging)) + { + SetAside(staging, "unpublished"); + } + + MoveDirectory(snapshotPath, staging); + PublishStagedSave(archive: true); + } + + private static void PublishStagedSave(bool archive) + { + var staging = StagedSavePath; + + if (archive) + { + EventSink.InvokeWorldSavePostSnapshot(SavePath, staging); + } + + if (Directory.Exists(SavePath)) + { + SetAside(SavePath, "previous"); + } + + MoveDirectory(staging, SavePath); + Directory.SetLastWriteTimeUtc(SavePath, Core.Now); + } + + /// + /// Finishes an interrupted publish. Runs at boot (before load) and before every save; + /// whatever is at Saves/ is set aside, never deleted. + /// + internal static void RecoverStagedSave() + { + var staging = StagedSavePath; + + if (!Directory.Exists(staging)) + { + return; + } + + logger.Warning( + "A complete world save was staged at {Staging} but never published; publishing it now.", + staging + ); + + PublishStagedSave(archive: false); + } + + private static void SetAside(string path, string reason) + { + var aside = $"{path}.{reason}-{Core.Now:yyyy-MM-dd-HH-mm-ss-fff}"; + MoveDirectory(path, aside); + logger.Warning("Set aside {Path} as {Aside}; delete or archive it by hand.", path, aside); + } + + // Atomic rename on one volume, file-by-file move otherwise. + private static void MoveDirectory(string source, string destination) + { + try + { + Directory.Move(source, destination); + } + catch (IOException) + { + if (Directory.Exists(destination)) + { + throw; + } + + PathUtility.MoveDirectoryContents(source, destination); + } + } + private static void FinishWorldSave() { WorldState = WorldState.Running; From 392c4e16d586c54b2e2d56cded3ab16144dac584 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:11:51 -0700 Subject: [PATCH 04/21] refactor: Changes BaseCreature to the SerializationGenerator (delta save requirement) (#2611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure SerializationGenerator conversion of `BaseCreature`, split out of #2592 so it can serve as the reference for converting every other large hand-written class in the Delta Saves project (#7, phase 3). Two behaviour changes from #2592 are deliberately not here and follow in their own PRs on top of this one: `SpeedClass`, and the collapse of `ControlMaster`/`SummonMaster` into one reference (a creature can lose or keep either independently: Blade Spirits and Energy Vortexes are summoned but never controlled, EnragedCreature and talisman summons keep a summon master with neither flag set). ## What this is - `BaseCreature` becomes `[SerializationGenerator(23, false)]` with a `[SerializableField]` per serialized slot and `[SaveFlag]` elision on nearly every field, so a stock creature serializes to its version plus flags. Field orders run 0..53 with no gaps (54 fields). - The hand-written reader stays as `private void Deserialize(IGenericReader reader, int version)` for every pre-codegen version (0..22); post-codegen bumps use `MigrateFrom` from here on. `[AfterDeserialization]` carries the post-load fixups main did after reading (stat timers, AI type, followers, reacquire seeding). - `ControlMaster` and `SummonMaster` stay two independent fields with main's semantics, each elided when null. - `DamageMin`/`DamageMax`/`ActiveSpeed`/`PassiveSpeed` are no longer virtual (nothing in the tree overrode them); comments swept to the constraints that matter. - Direct writes to serialized backing fields outside the generated setters (`SetDamage`, `SetResistance`, the move-speed helpers, the loot flag, feed loyalty, the delete timer) call `this.MarkDirty()`, matching the #2609 standard, so the class is ready for delta saves once `Mobile` is audited. - Schema `Server.Mobiles.BaseCreature.v23.json` regenerated by the tool (a second run produces no diff). ## Deferred to follow-up PRs SpeedClass: the serialized `_speedClass` field, `DefaultSpeedClass` replacing the type constant, `ApplySpeedClass`/`OnSpeedClassChange`, "None means custom", the four-speeds-as-one-block elision, `NPCSpeeds.FindEntry(SpeedLevel)`, the constructor fallback to Medium, and their tests. Master references: serializing one `Master` with a `Controlled`/`Summoned` fan-out and the `SetControlMaster` lockstep. ## Tests UOContent.Tests 776 / Server.Tests 855 green. `BaseCreatureSerializationTests` covers: a default creature elides to version + flags; a populated creature round-trips with exact byte consumption; back-to-back saves are byte-identical; an uncontrolled summon keeps its SummonMaster; byte-authentic v22 legacy streams (replicas of main's `Serialize`) load through the legacy reader for a wild tamable, a controlled pet, a controlled summon with an anchored `SummonEnd`, and a summon-master-only creature (the EnragedCreature shape); a running delete timer round-trips through `[DeserializeTimer]`; `Friends`, `CurrentWayPoint` and `HomeMap` round-trip; a `BaseVendor` stub round-trips the generated BaseVendor v2 → generated BaseCreature v23 chain. ## Behaviour notes for reviewers - `ActiveMoveSpeed`/`PassiveMoveSpeed` getters return the raw override (0 = inherit); `CurrentMoveSpeed` is the resolved pace. - Speeds elided as table defaults re-snap to the current `npc-speeds.json` on load, so table edits reach unmodified spawns on restart. - `GetSpeeds` no longer throws on the save/load path when the table has no entry for the type: saves elide against the creature's own values and loads keep the stream. Construction still throws (`InvalidOperationException`, was `KeyNotFoundException`). An elided load with no table entry would otherwise resume at speed 0, so `[AfterDeserialization]` logs once and paces it at Medium. - `virtual` removed from `ActiveSpeed`, `PassiveSpeed`, `DamageMin`, `DamageMax` (no overrides in the tree; forks may have some). - `ControlMaster`, `SummonMaster`, `ControlOrder`, `Tamable`, `IsParagon` are `[SerializableProperty]` over hand-written setters because follower bookkeeping must run before the assignment, which a `fieldChanged` hook cannot express; the wire format is identical. ## Prerequisites for cherry-picking #2609 (BaseVendor is already generated on top of BaseCreature) and SerializationGenerator 4.1.0. --- .../Tests/Mobiles/AI/MoveSpeedTests.cs | 25 +- .../Mobiles/BaseCreatureSerializationTests.cs | 455 +++++ .../Spells/Necromancy/BloodOathSpellTests.cs | 2 +- .../Engines/CannedEvil/ChampionTitleSystem.cs | 1 - .../PlayerMurderSystem.cs | 1 - .../UOContent/Engines/Virtues/VirtueSystem.cs | 1 - .../UOContent/Items/Misc/ProjectedItem.cs | 1 - .../Items/Weapons/BaseWeapon.Migrations.cs | 1 - .../Server.Mobiles.BaseCreature.v23.json | 471 ++++++ .../Mobiles/Abilities/MonsterAbility.cs | 4 +- Projects/UOContent/Mobiles/BaseCreature.cs | 1490 +++++++++-------- Projects/UOContent/Mobiles/CreatureEvents.cs | 13 + .../Mobiles/Monsters/LBR/Meers/MeerMage.cs | 2 +- Projects/UOContent/Mobiles/NPCSpeeds.cs | 24 +- Projects/UOContent/Skills/AntiMacroSystem.cs | 1 - Projects/UOContent/Skills/DetectHidden.cs | 1 - .../Spells/Necromancy/BloodOathSpell.cs | 4 +- .../Spells/Spellweaving/GiftOfLife.cs | 2 +- dev-docs/content-patterns.md | 5 +- 19 files changed, 1763 insertions(+), 741 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json create mode 100644 Projects/UOContent/Mobiles/CreatureEvents.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs index 7d9243a29..8637c5e59 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable { var bc = NewCreature(); - Assert.Equal(0.3, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); + // 0 = no override; the resolved pace comes from CurrentMoveSpeed. + Assert.Equal(0, bc.ActiveMoveSpeed); + Assert.Equal(0, bc.PassiveMoveSpeed); Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); } @@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable bc.SetSpeed(0.2, 0.4); - Assert.Equal(0.2, bc.ActiveMoveSpeed); - Assert.Equal(0.4, bc.PassiveMoveSpeed); + Assert.Equal(0, bc.ActiveMoveSpeed); + Assert.Equal(0, bc.PassiveMoveSpeed); } [Fact] @@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable bc.ActiveMoveSpeed = 0; - Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again + Assert.Equal(0, bc.ActiveMoveSpeed); // inheriting again Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock } [Fact] @@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable bc.ScaleMoveSpeed(1.0 / 1.2); Assert.Equal(0.5, bc.ActiveMoveSpeed); - Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar + Assert.Equal(0, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar } [Fact] @@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable bc.MigrateMoveSpeeds(); - Assert.Equal(0.35, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock + Assert.Equal(0, bc.PassiveMoveSpeed); } [Theory] @@ -211,9 +214,9 @@ public class MoveSpeedTests : IDisposable var reader = new BufferReader(buffer); copy.Deserialize(reader); - // The v22 tail is the last block; exact consumption catches any offset mistake. + // The BaseCreature tail is the last block; exact consumption catches any offset mistake. Assert.Equal(buffer.Length, reader.Position); - Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); - Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); + Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed); + Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs new file mode 100644 index 000000000..51a4de573 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +// BaseCreature's move to the SerializationGenerator (v23) is guarded three ways: the new +// SaveFlag format round-trips both a default and a fully-populated creature with exact +// byte consumption, back-to-back saves are byte-identical (freeze-time stability), and a +// byte-authentic pre-codegen v22 stream (written by a fossilized replica of the old +// Serialize) loads through the legacy path with the table-speed migration applied. +[Collection("Sequential UOContent Tests")] +public class BaseCreatureSerializationTests : IDisposable +{ + private readonly List _created = new(); + private readonly List _createdItems = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + + for (var i = 0; i < _createdItems.Count; i++) + { + _createdItems[i].Delete(); + } + } + + private class CreatureStub : BaseCreature + { + public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9; + + public CreatureStub(Serial serial) : base(serial) => Body = 0xC9; + + public DateTime SummonEndValue => SummonEnd; + + // Stands in for the npc-speeds table (unconfigured in the test fixture). + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + + public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + activeMoveSpeed = 0.6; + passiveMoveSpeed = 1.2; + } + } + + private CreatureStub NewCreature() + { + var bc = new CreatureStub(); + _created.Add(bc); + return bc; + } + + // ReadEntity resolves references through the world table, so the master must be registered. + private PlayerMobile NewMaster() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + World.AddEntity(master); + _created.Add(master); + return master; + } + + private static byte[] Snapshot(Mobile m) + { + var writer = new BufferWriter(true); + m.Serialize(writer); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + return buffer; + } + + private CreatureStub Load(byte[] buffer) + { + var copy = new CreatureStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + Assert.Equal(buffer.Length, reader.Position); // exact consumption + return copy; + } + + [Fact] + public void DefaultCreature_RoundTrips_AndElidesEverything() + { + var bc = NewCreature(); + + var buffer = Snapshot(bc); + var copy = Load(buffer); + + Assert.Equal(AIType.AI_Melee, copy.AI); + Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception); + Assert.Equal(0.3, copy.ActiveSpeed); + Assert.Equal(0.6, copy.PassiveSpeed); + Assert.Equal(0.6, copy.CurrentSpeed); + Assert.Equal(0.6, copy.ActiveMoveSpeed); // pulled from the table, not the wire + Assert.Equal(1.2, copy.PassiveMoveSpeed); + Assert.Equal(100, copy.PhysicalDamage); + Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty); + Assert.Equal(1, copy.ControlSlots); + Assert.NotNull(copy.Owners); + Assert.Empty(copy.Owners); + } + + [Fact] + public void BackToBackSaves_AreByteIdentical() + { + var bc = NewCreature(); + bc.SetDamage(5, 10); + bc.PhysicalResistanceSeed = 25; + + Assert.Equal(Snapshot(bc), Snapshot(bc)); + } + + [Fact] + public void PopulatedCreature_RoundTrips() + { + var bc = NewCreature(); + var master = NewMaster(); + + bc.Tamable = true; + bc.MinTameSkill = 47.1; + bc.SetControlMaster(master); + bc.Owners.Add(master); + bc.ControlOrder = OrderType.Guard; + bc.SetDamage(11, 17); + bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table + bc.SetMoveSpeed(0.25, 0.5); + bc.PhysicalResistanceSeed = 40; + bc.EnergyResistSeed = 15; + bc.FireDamage = 25; + bc.PhysicalDamage = 75; + bc.HitsMaxSeed = 250; + bc.Loyalty = 55; + bc.Home = new Point3D(1000, 1100, 5); + bc.RangeHome = 4; + bc.Team = 3; + bc.IsBonded = true; + bc.BondingBegin = Core.Now; + bc.RemoveIfUntamed = true; + bc.RemoveStep = 2; + bc.CorpseNameOverride = "a test corpse"; + + var copy = Load(Snapshot(bc)); + + Assert.True(copy.Controlled); + Assert.Equal(master, copy.ControlMaster); + Assert.Equal(OrderType.Guard, copy.ControlOrder); + Assert.True(copy.Tamable); + Assert.Equal(47.1, copy.MinTameSkill); + Assert.Equal(11, copy.DamageMin); + Assert.Equal(17, copy.DamageMax); + Assert.Equal(0.2, copy.ActiveSpeed); + Assert.Equal(0.4, copy.PassiveSpeed); + Assert.Equal(0.25, copy.ActiveMoveSpeed); + Assert.Equal(0.5, copy.PassiveMoveSpeed); + Assert.Equal(40, copy.PhysicalResistanceSeed); + Assert.Equal(15, copy.EnergyResistSeed); + Assert.Equal(25, copy.FireDamage); + Assert.Equal(75, copy.PhysicalDamage); + Assert.Equal(250, copy.HitsMaxSeed); + Assert.Equal(55, copy.Loyalty); + Assert.Equal(new Point3D(1000, 1100, 5), copy.Home); + Assert.Equal(4, copy.RangeHome); + Assert.Equal(3, copy.Team); + Assert.True(copy.IsBonded); + Assert.Equal(bc.BondingBegin, copy.BondingBegin); + Assert.True(copy.RemoveIfUntamed); + Assert.Equal(2, copy.RemoveStep); + Assert.Equal("a test corpse", copy.CorpseNameOverride); + Assert.Equal(master, copy.LastOwner); + } + + [Fact] + public void UncontrolledSummon_KeepsItsSummonMaster() + { + var bc = NewCreature(); + var master = NewMaster(); + + // Energy vortex-style: summoned with a master, never controlled. + bc.Summoned = true; + bc.SummonMaster = master; + + var copy = Load(Snapshot(bc)); + + Assert.True(copy.Summoned); + Assert.False(copy.Controlled); + Assert.Equal(master, copy.SummonMaster); + Assert.Null(copy.ControlMaster); + } + + [Fact] + public void ReferenceFields_RoundTrip() + { + var bc = NewCreature(); + var friend = NewMaster(); + var wayPoint = new WayPoint(); + _createdItems.Add(wayPoint); + + bc.AddPetFriend(friend); + bc.CurrentWayPoint = wayPoint; + bc.HomeMap = Map.Felucca; + + var copy = Load(Snapshot(bc)); + + Assert.Equal(friend, Assert.Single(copy.Friends)); + Assert.Equal(wayPoint, copy.CurrentWayPoint); + Assert.Equal(Map.Felucca, copy.HomeMap); + } + + [Fact] + public void RunningDeleteTimer_RoundTrips() + { + var bc = NewCreature(); + bc.BeginDeleteTimer(); + Assert.True(bc.DeleteTimeLeft > TimeSpan.Zero); + + var copy = Load(Snapshot(bc)); + + // Anchored: the remaining countdown survives, not the absolute deadline. + Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0)); + } + + private sealed class VendorStub : BaseVendor + { + private static readonly List _sbInfos = []; + + public VendorStub() : base("the stub") + { + } + + public VendorStub(Serial serial) : base(serial) + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() + { + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + // BaseVendor is generated on top of the generated BaseCreature; the chain must write + // and read both sections in order with exact consumption. + [Fact] + public void GeneratedVendorChain_RoundTrips() + { + var vendor = new VendorStub(); + _created.Add(vendor); + vendor.Home = new Point3D(1500, 1600, 0); + vendor.RangeHome = 2; + + var buffer = Snapshot(vendor); + + var copy = new VendorStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(AIType.AI_Vendor, copy.AI); + Assert.Equal(FightMode.None, copy.FightMode); + Assert.Equal(new Point3D(1500, 1600, 0), copy.Home); + Assert.Equal(2, copy.RangeHome); + } + + private sealed class MobileStub : Mobile + { + public MobileStub() => Body = 0xC9; + } + + // Byte-authentic replica of the pre-codegen v22 tail — fossilized so the legacy + // upgrade path stays covered without an old save binary. The full stream is a plain + // Mobile section (identical layout for every Mobile subclass) followed by this tail. + private static void WriteLegacyV22Tail( + IGenericWriter writer, + bool controlled = false, + Mobile controlMaster = null, + bool summoned = false, + Mobile summonMaster = null, + DateTime summonEnd = default + ) + { + writer.Write(22); // version + writer.Write((int)AIType.AI_Melee); // current AI + writer.Write((int)AIType.AI_Melee); // default AI + writer.Write(10); // RangePerception + writer.Write(1); // RangeFight + writer.Write(0); // Team + writer.Write(0.3); // active (matches the stub table) + writer.Write(0.6); // passive + writer.Write(0.6); // current + writer.Write(2000); // Home X + writer.Write(2100); // Home Y + writer.Write(7); // Home Z + writer.Write(6); // RangeHome + writer.Write((int)FightMode.Closest); + writer.Write(controlled); + writer.Write(controlMaster); + writer.Write((Mobile)null); // control target + writer.Write(Point3D.Zero); // control dest + writer.Write((int)OrderType.None); + writer.Write(0.0); // min tame skill + writer.Write(true); // tamable + writer.Write(summoned); + if (summoned) + { + writer.WriteAnchoredTime(summonEnd); + } + + writer.Write(2); // control slots + writer.Write(73); // loyalty + writer.Write((Item)null); // waypoint + writer.Write(summonMaster); + writer.Write(180); // hits seed + writer.Write(-1); // stam seed + writer.Write(-1); // mana seed + writer.Write(7); // damage min + writer.Write(14); // damage max + writer.Write(30); // phys resist + writer.Write(100); // phys damage + writer.Write(10); // fire resist + writer.Write(0); // fire damage + writer.Write(0); // cold resist + writer.Write(0); // cold damage + writer.Write(0); // poison resist + writer.Write(0); // poison damage + writer.Write(0); // energy resist + writer.Write(0); // energy damage + writer.Write(new List()); // owners + writer.Write(false); // dead pet + writer.Write(false); // bonded + writer.Write(DateTime.MinValue); // bonding begin + writer.Write(DateTime.MinValue); // abandon time + writer.Write(true); // has generated loot + writer.Write(false); // paragon + writer.Write(false); // has friends + writer.Write(false); // remove if untamed + writer.Write(0); // remove step + writer.Write(TimeSpan.Zero); // delete time left + writer.Write((string)null); // corpse name override + writer.Write((Map)null); // home map + writer.Write(0.0); // active move speed (v22) + writer.Write(0.0); // passive move speed (v22) + } + + private CreatureStub LoadLegacyV22( + bool controlled = false, + Mobile controlMaster = null, + bool summoned = false, + Mobile summonMaster = null, + DateTime summonEnd = default + ) + { + // Every serialized BaseCreature starts with the Mobile base section; a plain + // Mobile donor produces a byte-authentic one. + var donor = new MobileStub(); + donor.DefaultMobileInit(); + _created.Add(donor); + + var writer = new BufferWriter(true); + donor.Serialize(writer); + WriteLegacyV22Tail(writer, controlled, controlMaster, summoned, summonMaster, summonEnd); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new CreatureStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + Assert.Equal(buffer.Length, reader.Position); + return copy; + } + + [Fact] + public void LegacyV22Stream_LoadsThroughLegacyPath() + { + var copy = LoadLegacyV22(); + + Assert.Equal(10, copy.RangePerception); + Assert.Equal(new Point3D(2000, 2100, 7), copy.Home); + Assert.Equal(6, copy.RangeHome); + Assert.True(copy.Tamable); + Assert.Equal(2, copy.ControlSlots); + Assert.Equal(73, copy.Loyalty); + Assert.Equal(180, copy.HitsMaxSeed); + Assert.Equal(7, copy.DamageMin); + Assert.Equal(14, copy.DamageMax); + Assert.Equal(30, copy.PhysicalResistanceSeed); + Assert.Equal(10, copy.FireResistSeed); + Assert.Equal(0.3, copy.ActiveSpeed); + // v22 wrote explicit zeros for the move overrides ("inherit"), so the resolved + // pace falls back to the think clock. + Assert.Equal(0, copy.ActiveMoveSpeed); + Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting + } + + // ControlMaster and SummonMaster are independent references: a summon master can + // exist without Summoned (EnragedCreature), and a controlled summon carries both. + [Theory] + [InlineData(true, true, false, false)] // controlled pet + [InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire + [InlineData(false, false, false, true)] // EnragedCreature shape: SummonMaster only + public void LegacyV22Stream_KeepsBothMasterReferences( + bool controlled, + bool hasControlMaster, + bool summoned, + bool hasSummonMaster + ) + { + var master = NewMaster(); + var summonEnd = Core.Now + TimeSpan.FromMinutes(5); + + var copy = LoadLegacyV22( + controlled, + hasControlMaster ? master : null, + summoned, + hasSummonMaster ? master : null, + summonEnd + ); + + Assert.Equal(controlled, copy.Controlled); + Assert.Equal(summoned, copy.Summoned); + Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster); + Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster); + + if (summoned) + { + Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1)); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs index 0264a282e..08658b1a1 100644 --- a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs +++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs @@ -121,7 +121,7 @@ public class BloodOathSpellTests BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5)); - BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side + CreatureEvents.CreatureDeletedEvent(target); // central handler breaks the oath from the target side Assert.Null(BloodOathSpell.GetBloodOath(target)); Assert.False(BloodOathSpell.RemoveCurse(caster)); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index 6160c4595..4c74ee832 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Mobiles; namespace Server.Engines.CannedEvil; diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index d1be82aa9..80ba553fc 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Misc; using Server.Mobiles; diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index 457899b91..9a0fd7148 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Mobiles; diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index 03e93d0ea..71e339074 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; -using Server.Collections; using Server.Network; namespace Server.Items; diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs index 3385598d9..78937f06f 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -1,5 +1,4 @@ using System; -using Server.Engines.Craft; namespace Server.Items; diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json new file mode 100644 index 000000000..b700154ab --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json @@ -0,0 +1,471 @@ +{ + "version": 23, + "type": "Server.Mobiles.BaseCreature", + "properties": [ + { + "name": "DefaultAI", + "type": "Server.Mobiles.AIType", + "rule": "EnumMigrationRule" + }, + { + "name": "CurrentAI", + "type": "Server.Mobiles.AIType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "RangePerception", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "RangeFight", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "RangeHome", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Team", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FightMode", + "type": "Server.Mobiles.FightMode", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "ActiveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PassiveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "CurrentSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActiveMoveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PassiveMoveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Home", + "type": "Server.Point3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "HomeMap", + "type": "Server.Map", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Controlled", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ControlMaster", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlTarget", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlDest", + "type": "Server.Point3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "ControlOrder", + "type": "Server.Mobiles.OrderType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "MinTameSkill", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Tamable", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Summoned", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SummonEnd", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "SummonMaster", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlSlots", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Loyalty", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "CurrentWayPoint", + "type": "Server.Items.WayPoint", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HitsMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StamMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ManaMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DamageMin", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DamageMax", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PhysicalResistanceSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PhysicalDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Owners", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "usesSaveFlag": true, + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "IsDeadPet", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IsBonded", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BondingBegin", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "OwnerAbandonTime", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HasGeneratedLoot", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IsParagon", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Friends", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "usesSaveFlag": true, + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RemoveIfUntamed", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RemoveStep", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PendingDeleteTimer", + "type": "Server.Timer", + "usesSaveFlag": true, + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "CorpseNameOverride", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs index fc227507e..75c6fc3dc 100644 --- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs +++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs @@ -99,8 +99,8 @@ public abstract class MonsterAbility { } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void InvalidateNextAbilityTriggers(BaseCreature source) { var abilities = source.GetMonsterAbilities(); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 404aaede8..5ea68cb13 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using ModernUO.CodeGeneratedEvents; +using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; using Server.Engines.ConPVP; @@ -13,6 +14,7 @@ using Server.Engines.Virtues; using Server.Ethics; using Server.Factions; using Server.Items; +using Server.Logging; using Server.Misc; using Server.Multis; using Server.Network; @@ -133,8 +135,17 @@ namespace Server.Mobiles public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage); } + [SerializationGenerator(23, false)] public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature)); + + // Medium bucket; used when an elided load finds no table entry (a 0-delay AI timer would spin). + private const double FallbackActiveSpeed = 0.25; + private const double FallbackPassiveSpeed = 0.5; + + private static bool _loggedMissingSpeeds; + public enum Allegiance { None, @@ -160,7 +171,7 @@ namespace Server.Mobiles public const int DefaultRangePerception = 16; - private const double ChanceToRummage = 0.5; // 50% + private const double ChanceToRummage = 0.5; private const double MinutesToNextRummageMin = 1.0; private const double MinutesToNextRummageMax = 4.0; @@ -227,7 +238,7 @@ namespace Server.Mobiles private static readonly Type[] _gold = { - // white wyrms eat gold.. + // White wyrms eat gold. typeof(Gold) }; @@ -250,54 +261,536 @@ namespace Server.Mobiles typeof(AncientSmithyHammer), typeof(Scorp) }; - private bool _summoned; + // --- Serialized state --------------------------------------------------------- + // Fields matching their defaults (including npc-speeds table values) are elided by [SaveFlag]. - private bool m_bTamable; - private int m_ColdResistance; + [SerializableField(0, setter: "private")] + private AIType _defaultAI; - private bool _controlled; // Is controlled - private Mobile m_ControlMaster; // My master - private OrderType m_ControlOrder; // My order + [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeCurrentAI), nameof(CurrentAIDefaultValue))] + private AIType _currentAI; - private AIType m_CurrentAI; // The current AI + private bool ShouldSerializeCurrentAI() => _currentAI != _defaultAI; + private AIType CurrentAIDefaultValue() => _defaultAI; + + [EncodedInt] + [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeRangePerception), nameof(RangePerceptionDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangePerception; + + private bool ShouldSerializeRangePerception() => _rangePerception != DefaultRangePerception; + + private int RangePerceptionDefaultValue() => DefaultRangePerception; + + [EncodedInt] + [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeRangeFight), nameof(RangeFightDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeFight; + + private bool ShouldSerializeRangeFight() => _rangeFight != 1; + + private int RangeFightDefaultValue() => 1; + + [EncodedInt] + [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeRangeHome), nameof(RangeHomeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeHome = 10; + + private bool ShouldSerializeRangeHome() => _rangeHome != 10; + + private int RangeHomeDefaultValue() => 10; + + [EncodedInt] + [SerializableField(5, fieldChanged: nameof(OnTeamChange))] + [SaveFlag(nameof(ShouldSerializeTeam))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _team; + + private bool ShouldSerializeTeam() => _team != 0; + + private void OnTeamChange(int oldValue, int newValue) => OnTeamChange(); + + [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeFightMode), nameof(FightModeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private FightMode _fightMode; + + private bool ShouldSerializeFightMode() => _fightMode != FightMode.Closest; + + private FightMode FightModeDefaultValue() => FightMode.Closest; + + /// Seconds per AI decision while engaged; see for movement pace. + [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeActiveSpeed), nameof(ActiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeSpeed; + + private bool ShouldSerializeActiveSpeed() + { + GetSpeeds(out var activeSpeed, out _); + return _activeSpeed != activeSpeed; + } + + private double ActiveSpeedDefaultValue() + { + GetSpeeds(out var activeSpeed, out _); + return activeSpeed; + } + + /// Seconds per AI decision while idle; see for movement pace. + [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePassiveSpeed), nameof(PassiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveSpeed; + + private bool ShouldSerializePassiveSpeed() + { + GetSpeeds(out _, out var passiveSpeed); + return _passiveSpeed != passiveSpeed; + } + + private double PassiveSpeedDefaultValue() + { + GetSpeeds(out _, out var passiveSpeed); + return passiveSpeed; + } + + [SerializableField(9, fieldChanged: nameof(OnCurrentSpeedChange))] + [SaveFlag(nameof(ShouldSerializeCurrentSpeed), nameof(CurrentSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _currentSpeed; - // Movement clock (seconds per step); 0 = inherit the matching think value. + private bool ShouldSerializeCurrentSpeed() => _currentSpeed != _passiveSpeed; + + private double CurrentSpeedDefaultValue() => _passiveSpeed; + + private void OnCurrentSpeedChange(double oldValue, double newValue) => AIObject?.OnCurrentSpeedChanged(); + + /// + /// Movement clock (seconds per step) while engaged; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(10, allowFieldChange: nameof(CoerceMoveSpeed))] + [SaveFlag(nameof(ShouldSerializeActiveMoveSpeed), nameof(ActiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeMoveSpeed; + + /// + /// Movement clock (seconds per step) while idle; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed))] + [SaveFlag(nameof(ShouldSerializePassiveMoveSpeed), nameof(PassiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveMoveSpeed; + private bool CoerceMoveSpeed(ref double value) + { + value = Math.Max(0, value); // anything non-positive means "inherit" + return true; + } + + private bool ShouldSerializeActiveMoveSpeed() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return _activeMoveSpeed != activeMoveSpeed; + } + + private double ActiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return activeMoveSpeed; + } + + private bool ShouldSerializePassiveMoveSpeed() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return _passiveMoveSpeed != passiveMoveSpeed; + } + + private double PassiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return passiveMoveSpeed; + } + + [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeHome))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _home; + + private bool ShouldSerializeHome() => _home != Point3D.Zero; + + [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeHomeMap))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Map _homeMap; + + private bool ShouldSerializeHomeMap() => _homeMap != null; + + [SerializableField(14, fieldChanged: nameof(OnControlledChange))] + [SaveFlag(nameof(ShouldSerializeControlled))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _controlled; + + private bool ShouldSerializeControlled() => _controlled; + + private void OnControlledChange(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + // Follower bookkeeping brackets the assignment, so the property is hand-written. + private Mobile _controlMaster; + + private bool ShouldSerializeControlMaster() => _controlMaster != null; + + [SerializableField(16)] + [SaveFlag(nameof(ShouldSerializeControlTarget))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Mobile _controlTarget; + + private bool ShouldSerializeControlTarget() => _controlTarget != null; + + [SerializableField(17)] + [SaveFlag(nameof(ShouldSerializeControlDest))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _controlDest; + + private bool ShouldSerializeControlDest() => _controlDest != Point3D.Zero; + + // Order logic must run on equal re-assignment, so the property is hand-written. + private OrderType _controlOrder; + + private bool ShouldSerializeControlOrder() => _controlOrder != OrderType.None; + + [SerializableField(19)] + [SaveFlag(nameof(ShouldSerializeMinTameSkill))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private double _minTameSkill; + + private bool ShouldSerializeMinTameSkill() => _minTameSkill != 0; + + // The getter masks paragons, so the property is hand-written. + private bool _tamable; + + private bool ShouldSerializeTamable() => _tamable; + + [SerializableField(21, fieldChanged: nameof(OnSummonedChange))] + [SaveFlag(nameof(ShouldSerializeSummoned))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private bool _summoned; + + private bool ShouldSerializeSummoned() => _summoned; + + private void OnSummonedChange(bool oldValue, bool newValue) + { + NextReacquireTime = Core.TickCount; + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + [AnchoredDateTime] + [SerializableField(22, getter: "protected", setter: "protected")] + [SaveFlag(nameof(ShouldSerializeSummonEnd))] + private DateTime _summonEnd; + + private bool ShouldSerializeSummonEnd() => _summoned; + + // Follower bookkeeping brackets the assignment, so the property is hand-written. + private Mobile _summonMaster; + + private bool ShouldSerializeSummonMaster() => _summonMaster != null; + + [EncodedInt] + [SerializableField(24)] + [SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private int _controlSlots = 1; + + private bool ShouldSerializeControlSlots() => _controlSlots != 1; + + private int ControlSlotsDefaultValue() => 1; + + [EncodedInt] + [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))] + [SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _loyalty; + + private bool ShouldSerializeLoyalty() => _loyalty != MaxLoyalty; + + private int LoyaltyDefaultValue() => MaxLoyalty; + + private bool ClampLoyalty(ref int value) + { + value = Math.Clamp(value, 0, MaxLoyalty); + return true; + } + + [SerializableField(26)] + [SaveFlag(nameof(ShouldSerializeCurrentWayPoint))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private WayPoint _currentWayPoint; + + private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null; + + [EncodedInt] + [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _hitsMaxSeed = -1; + + private bool ShouldSerializeHitsMaxSeed() => _hitsMaxSeed != -1; + + private int HitsMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(28)] + [SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _stamMaxSeed = -1; + + private bool ShouldSerializeStamMaxSeed() => _stamMaxSeed != -1; + + private int StamMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _manaMaxSeed = -1; + + private bool ShouldSerializeManaMaxSeed() => _manaMaxSeed != -1; + + private int ManaMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(30)] + [SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMin = -1; + + private bool ShouldSerializeDamageMin() => _damageMin != -1; + + private int DamageMinDefaultValue() => -1; + + [EncodedInt] + [SerializableField(31)] + [SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMax = -1; + + private bool ShouldSerializeDamageMax() => _damageMax != -1; + + private int DamageMaxDefaultValue() => -1; + + [EncodedInt] + [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalResistanceSeed; + + private bool ShouldSerializePhysicalResistanceSeed() => _physicalResistanceSeed != 0; + + private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances(); + + [EncodedInt] + [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeFireResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireResistSeed; + + private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0; + + [EncodedInt] + [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeColdResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldResistSeed; + + private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0; + + [EncodedInt] + [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePoisonResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonResistSeed; + + private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0; + + [EncodedInt] + [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeEnergyResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyResistSeed; + + private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0; + + [EncodedInt] + [SerializableField(37)] + [SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalDamage = 100; + + private bool ShouldSerializePhysicalDamage() => _physicalDamage != 100; + + private int PhysicalDamageDefaultValue() => 100; + + [EncodedInt] + [SerializableField(38)] + [SaveFlag(nameof(ShouldSerializeFireDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireDamage; + + private bool ShouldSerializeFireDamage() => _fireDamage != 0; + + [EncodedInt] + [SerializableField(39)] + [SaveFlag(nameof(ShouldSerializeColdDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldDamage; + + private bool ShouldSerializeColdDamage() => _coldDamage != 0; + + [EncodedInt] + [SerializableField(40)] + [SaveFlag(nameof(ShouldSerializePoisonDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonDamage; + + private bool ShouldSerializePoisonDamage() => _poisonDamage != 0; + + [EncodedInt] + [SerializableField(41)] + [SaveFlag(nameof(ShouldSerializeEnergyDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyDamage; + + private bool ShouldSerializeEnergyDamage() => _energyDamage != 0; + + [Tidy] + [SerializableField(42, setter: "private")] + [SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))] + private List _owners; + + private bool ShouldSerializeOwners() + { + _owners?.Tidy(); + return _owners?.Count > 0; + } + + private List OwnersDefaultValue() => new(); + + [SerializableField(43)] + [SaveFlag(nameof(ShouldSerializeIsDeadPet))] + private bool _isDeadPet; + + private bool ShouldSerializeIsDeadPet() => _isDeadPet; + + [SerializableField(44, fieldChanged: nameof(OnBondedChange))] + [SaveFlag(nameof(ShouldSerializeIsBonded))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _isBonded; + + private bool ShouldSerializeIsBonded() => _isBonded; + + private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties(); + + [SerializableField(45)] + [SaveFlag(nameof(ShouldSerializeBondingBegin))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _bondingBegin; + + private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue; + + [SerializableField(46)] + [SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _ownerAbandonTime; + + private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue; + + [SerializableField(47)] + [SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))] + private bool _hasGeneratedLoot; + + private bool ShouldSerializeHasGeneratedLoot() => _hasGeneratedLoot; + + // The setter converts the creature, which must not run at load, so the property is hand-written. + private bool _isParagon; + + private bool ShouldSerializeIsParagon() => _isParagon; + + [Tidy] + [SerializableField(49, setter: "private")] + [SaveFlag(nameof(ShouldSerializeFriends))] + private List _friends; + + private bool ShouldSerializeFriends() + { + _friends?.Tidy(); + return _friends?.Count > 0; + } + + [SerializableField(50)] + [SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _removeIfUntamed; + + private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed; + + [EncodedInt] + [SerializableField(51)] + [SaveFlag(nameof(ShouldSerializeRemoveStep))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _removeStep; + + private bool ShouldSerializeRemoveStep() => _removeStep != 0; + + [SerializableField(52, setter: "private")] + [SaveFlag(nameof(ShouldSerializePendingDeleteTimer))] + [DeserializeTimer(nameof(DeserializePendingDeleteTimer))] + private Timer _pendingDeleteTimer; + + // Stabled and controlled pets never resume a delete countdown. + private bool ShouldSerializePendingDeleteTimer() => + _pendingDeleteTimer?.Running == true && !IsStabled && !(_controlled && _controlMaster != null); + + private void DeserializePendingDeleteTimer(TimeSpan delay) + { + _pendingDeleteTimer = new DeleteTimer(this, delay); + _pendingDeleteTimer.Start(); + } + + [SerializableField(53)] + [SaveFlag(nameof(ShouldSerializeCorpseNameOverride))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _corpseNameOverride; + + private bool ShouldSerializeCorpseNameOverride() => _corpseNameOverride != null; + + // --- Non-serialized state ------------------------------------------------------- + // Herding - forces the mob to walk to a specific location, paced by the movement // clock at HerdingMoveSpeed. Thinking is unaffected. private IPoint2D _targetLocation; - private int m_DamageMax = -1; - - private int m_DamageMin = -1; - private AIType m_DefaultAI; // The default AI - - private DeleteTimer m_DeleteTimer; - private int m_EnergyResistance; - private int m_FailedReturnHome; /* return to home failure counter */ - private int m_FireResistance; - private bool m_HasGeneratedLoot; // have we generated our loot yet? private TimerExecutionToken _healTimerToken; - private Point3D m_Home; // The home position of the creature, used by some AI - private DateTime m_IdleReleaseTime; - private bool m_IsBonded; - private bool m_IsStabled; protected int m_KillersLuck; - private int m_Loyalty; - private DateTime m_MLNextShout; private List m_MLQuests; @@ -310,24 +803,13 @@ namespace Server.Mobiles private long m_NextRummageTime; - private bool m_Paragon; - - private int m_PhysicalResistance; - private int m_PoisonResistance; - - /* until we are sure about who should be getting deleted, move them instead */ - /* On OSI, they despawn */ - + // On OSI these despawn; we queue a return home instead of deleting. private bool m_ReturnQueued; protected bool m_Spawning; - private Mobile m_SummonMaster; - private SkillName m_Teaching = (SkillName)(-1); - private int m_Team; // Monster Team - public BaseCreature( AIType ai, FightMode mode = FightMode.Closest, @@ -335,10 +817,10 @@ namespace Server.Mobiles int iRangeFight = 1 ) { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; - m_CurrentAI = ai; - m_DefaultAI = ai; + _currentAI = ai; + _defaultAI = ai; RangePerception = iRangePerception; RangeFight = iRangeFight; @@ -348,20 +830,28 @@ namespace Server.Mobiles GetSpeeds(out var activeSpeed, out var passiveSpeed); GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + if (activeSpeed <= 0 || passiveSpeed <= 0) + { + // A 0-delay creature would spin its AI timer; only construction refuses. + throw new InvalidOperationException( + $"{GetType()} constructed without speeds - is Data/npc-speeds.json missing?" + ); + } + ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; CurrentSpeed = passiveSpeed; - m_Team = 0; + _team = 0; Debug = false; _controlled = false; - m_ControlMaster = null; + _controlMaster = null; ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlOrder = OrderType.None; - m_bTamable = false; + _tamable = false; Owners = new List(); @@ -406,14 +896,10 @@ namespace Server.Mobiles public virtual InhumanSpeech SpeechType => null; - /* Do not serialize this till the code is finalized */ - + // Deliberately not serialized until the feature is finalized. [CommandProperty(AccessLevel.GameMaster)] public bool SeeksHome { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public string CorpseNameOverride { get; set; } - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool IsStabled { @@ -436,20 +922,20 @@ namespace Server.Mobiles public virtual bool FollowsAcquireRules => true; - protected DateTime SummonEnd { get; set; } - public virtual Faction FactionAllegiance => null; public virtual int FactionSilverWorth => 30; public virtual double WeaponAbilityChance => 0.4; + [SerializableProperty(48, useField: nameof(_isParagon))] + [SaveFlag(nameof(ShouldSerializeIsParagon))] [CommandProperty(AccessLevel.GameMaster)] public bool IsParagon { - get => m_Paragon; + get => _isParagon; set { - if (m_Paragon == value) + if (_isParagon == value) { return; } @@ -463,9 +949,10 @@ namespace Server.Mobiles Paragon.UnConvert(this); } - m_Paragon = value; + _isParagon = value; InvalidateProperties(); + this.MarkDirty(); } } @@ -474,8 +961,6 @@ namespace Server.Mobiles public virtual FoodType FavoriteFood => FoodType.Meat; public virtual PackInstinct PackInstinct => PackInstinct.None; - public List Owners { get; private set; } - public virtual bool AllowMaleTamer => true; public virtual bool AllowFemaleTamer => true; public virtual bool SubdueBeforeTame => false; @@ -499,11 +984,11 @@ namespace Server.Mobiles public virtual bool DeathAdderCharmable => false; - // TODO: Find the pub 31 tweaks to the DispelDifficulty and apply them of course. - // at this skill level we dispel 50% chance + //TODO Apply the pub 31 DispelDifficulty tweaks + // Skill level at which dispel succeeds 50% of the time. public virtual double DispelDifficulty => 0.0; - // at difficulty - focus we have 0%, at difficulty + focus we have 100% + // 0% at difficulty - focus, 100% at difficulty + focus. public virtual double DispelFocus => 20.0; public virtual bool DisplayWeight => Backpack is StrongBackpack; @@ -539,21 +1024,11 @@ namespace Server.Mobiles } public virtual bool IsNecroFamiliar => - Summoned && m_ControlMaster != null && - SummonFamiliarSpell.Table.TryGetValue(m_ControlMaster, out var bc) && bc == this; + Summoned && _controlMaster != null && + SummonFamiliarSpell.Table.TryGetValue(_controlMaster, out var bc) && bc == this; public virtual bool DeleteCorpseOnDeath => !Core.AOS && _summoned; - [CommandProperty(AccessLevel.GameMaster)] - public int Loyalty - { - get => m_Loyalty; - set => m_Loyalty = Math.Clamp(value, 0, MaxLoyalty); - } - - [CommandProperty(AccessLevel.GameMaster)] - public WayPoint CurrentWayPoint { get; set; } - public virtual Mobile ConstantFocus => null; public virtual bool DisallowAllMoves => false; @@ -566,57 +1041,25 @@ namespace Server.Mobiles public virtual bool AlwaysAttackable => false; - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMin - { - get => m_DamageMin; - set => m_DamageMin = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMax - { - get => m_DamageMax; - set => m_DamageMax = value; - } - [CommandProperty(AccessLevel.GameMaster)] public override int HitsMax => HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int HitsMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int StamMax => StamMaxSeed <= 0 ? Dex : Math.Clamp(StamMaxSeed + GetStatOffset(StatType.Dex), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int StamMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int ManaMax => ManaMaxSeed <= 0 ? Int : Math.Clamp(ManaMaxSeed + GetStatOffset(StatType.Int), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int ManaMaxSeed { get; set; } = -1; - public virtual bool CanOpenDoors => !Body.IsAnimal && !Body.IsSea; public virtual bool CanMoveOverObstacles => Core.AOS || Body.IsMonster; public virtual bool CanDestroyObstacles => false; - /* - Seems this actually was removed on OSI somewhere between the original bug report and now. - We will call it ML, until we can get better information. I suspect it was on the OSI TC when - originally it taken out of RunUO, and not implemented on OSIs production shards until more - recently. Either way, this is, or was, accurate OSI behavior, and just entirely - removing it was incorrect. OSI followers were distracted by being attacked well into - AoS, at very least. - - */ - + // OSI followers were distracted by attacks well into AoS; removed around ML. public virtual bool CanBeDistracted => !Core.ML; public override bool ShouldCheckStatTimers => false; @@ -628,43 +1071,27 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public AIType AI { - get => m_CurrentAI; + get => _currentAI; set { - m_CurrentAI = value; + _currentAI = value; - if (m_CurrentAI == AIType.AI_Use_Default) + if (_currentAI == AIType.AI_Use_Default) { - m_CurrentAI = m_DefaultAI; + _currentAI = _defaultAI; } - ChangeAIType(m_CurrentAI); + this.MarkDirty(); + ChangeAIType(_currentAI); } } [CommandProperty(AccessLevel.Administrator)] public bool Debug { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public int Team - { - get => m_Team; - set - { - m_Team = value; - OnTeamChange(); - } - } - [CommandProperty(AccessLevel.GameMaster)] public Mobile FocusMob { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public FightMode FightMode { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangePerception { get; set; } - /// /// How far a chase may stretch before the creature gives up its combatant. Between /// RangePerception and this leash it keeps chasing but may switch to closer targets. @@ -672,57 +1099,6 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public virtual int ChaseLeashRange => RangePerception * 2; - [CommandProperty(AccessLevel.GameMaster)] - public int RangeFight { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangeHome { get; set; } = 10; - - /// Seconds per AI decision while engaged; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveSpeed - { - get => _activeSpeed; - set - { - if (Math.Abs(_activeSpeed - value) > .0001) - { - _activeSpeed = value; - } - } - } - - /// Seconds per AI decision while idle; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveSpeed - { - get => _passiveSpeed; - set - { - _passiveSpeed = value; - if (Math.Abs(_passiveSpeed - value) > .0001) - { - _passiveSpeed = value; - } - } - } - - /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveMoveSpeed - { - get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; - set => _activeMoveSpeed = value > 0 ? value : 0; - } - - /// Seconds per step while idle. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveMoveSpeed - { - get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; - set => _passiveMoveSpeed = value > 0 ? value : 0; - } - // Herded creatures walk at a fixed standard pace regardless of their own speed // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). private const double HerdingMoveSpeed = 0.3; @@ -734,20 +1110,6 @@ namespace Server.Mobiles set => _targetLocation = value; } - [CommandProperty(AccessLevel.GameMaster)] - public double CurrentSpeed - { - get => _currentSpeed; - set - { - if (Math.Abs(_currentSpeed - value) > 0.0001) - { - _currentSpeed = value; - AIObject?.OnCurrentSpeedChanged(); - } - } - } - /// /// Resolved seconds per step: a verbatim active/passive /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) @@ -764,104 +1126,87 @@ namespace Server.Mobiles return HerdingMoveSpeed; } - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed - : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed - : _currentSpeed; - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D Home - { - get => m_Home; - set => m_Home = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public Map HomeMap { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public bool Controlled - { - get => _controlled; - set - { - if (_controlled == value) + if (_currentSpeed == _activeSpeed) { - return; + return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; } - _controlled = value; - Delta(MobileDelta.Noto); + if (_currentSpeed == _passiveSpeed) + { + return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + } - InvalidateProperties(); + return _currentSpeed; } } + [SerializableProperty(15, useField: nameof(_controlMaster))] + [SaveFlag(nameof(ShouldSerializeControlMaster))] [CommandProperty(AccessLevel.GameMaster)] public Mobile ControlMaster { - get => m_ControlMaster; + get => _controlMaster; set { - if (m_ControlMaster == value || this == value) + if (_controlMaster == value || this == value) { return; } RemoveFollowers(); - m_ControlMaster = value; + _controlMaster = value; AddFollowers(); - if (m_ControlMaster != null) + if (_controlMaster != null) { StopDeleteTimer(); } Delta(MobileDelta.Noto); + this.MarkDirty(); } } + [SerializableProperty(23, useField: nameof(_summonMaster))] + [SaveFlag(nameof(ShouldSerializeSummonMaster))] [CommandProperty(AccessLevel.GameMaster)] public Mobile SummonMaster { - get => m_SummonMaster; + get => _summonMaster; set { - if (m_SummonMaster == value || this == value) + if (_summonMaster == value || this == value) { return; } RemoveFollowers(); - m_SummonMaster = value; + _summonMaster = value; AddFollowers(); Delta(MobileDelta.Noto); + this.MarkDirty(); } } - [CommandProperty(AccessLevel.GameMaster)] - public Mobile ControlTarget { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D ControlDest { get; set; } - // Fires on every assignment, not only changes: a reissued order is a command // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. + [SerializableProperty(18, useField: nameof(_controlOrder))] + [SaveFlag(nameof(ShouldSerializeControlOrder))] [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { - get => m_ControlOrder; + get => _controlOrder; set { - var previous = m_ControlOrder; - m_ControlOrder = value; + var previous = _controlOrder; + _controlOrder = value; AIObject?.OnCurrentOrderChanged(previous); InvalidateProperties(); - m_ControlMaster?.InvalidateProperties(); + _controlMaster?.InvalidateProperties(); + this.MarkDirty(); } } @@ -880,39 +1225,19 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public DateTime BardEndTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public double MinTameSkill { get; set; } - + [SerializableProperty(20, useField: nameof(_tamable))] + [SaveFlag(nameof(ShouldSerializeTamable))] [CommandProperty(AccessLevel.GameMaster)] public bool Tamable { - get => m_bTamable && !m_Paragon; - set => m_bTamable = value; - } - - [CommandProperty(AccessLevel.Administrator)] - public bool Summoned - { - get => _summoned; + get => _tamable && !_isParagon; set { - if (_summoned == value) - { - return; - } - - NextReacquireTime = Core.TickCount; - - _summoned = value; - Delta(MobileDelta.Noto); - - InvalidateProperties(); + _tamable = value; + this.MarkDirty(); } } - [CommandProperty(AccessLevel.Administrator)] - public int ControlSlots { get; set; } = 1; - public virtual bool NoHouseRestrictions => false; public virtual bool IsHouseSummonable => false; @@ -943,7 +1268,7 @@ namespace Server.Mobiles // 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); + public virtual TimeSpan AcquireOnApproachDelay => _isParagon ? 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. @@ -990,13 +1315,6 @@ namespace Server.Mobiles public virtual bool ReturnsToHome => SeeksHome && Home != Point3D.Zero && !m_ReturnQueued && !Controlled && !Summoned; - // used for deleting untamed creatures [in houses] - [CommandProperty(AccessLevel.GameMaster)] - public bool RemoveIfUntamed { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RemoveStep { get; set; } - public virtual bool CanGiveMLQuest => MLQuests.Count != 0; public virtual bool StaticMLQuester => true; @@ -1031,114 +1349,25 @@ namespace Server.Mobiles } } - [CommandProperty(AccessLevel.GameMaster)] - public bool IsBonded - { - get => m_IsBonded; - set - { - m_IsBonded = value; - InvalidateProperties(); - } - } - - public bool IsDeadPet { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime BondingBegin { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime OwnerAbandonTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public TimeSpan DeleteTimeLeft { get { - if (m_DeleteTimer?.Running == true) + if (_pendingDeleteTimer?.Running == true) { - return m_DeleteTimer.Next - Core.Now; + return _pendingDeleteTimer.Next - Core.Now; } return TimeSpan.Zero; } } - public override int BasePhysicalResistance => m_PhysicalResistance; - public override int BaseFireResistance => m_FireResistance; - public override int BaseColdResistance => m_ColdResistance; - public override int BasePoisonResistance => m_PoisonResistance; - public override int BaseEnergyResistance => m_EnergyResistance; - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalResistanceSeed - { - get => m_PhysicalResistance; - set - { - m_PhysicalResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int FireResistSeed - { - get => m_FireResistance; - set - { - m_FireResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdResistSeed - { - get => m_ColdResistance; - set - { - m_ColdResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonResistSeed - { - get => m_PoisonResistance; - set - { - m_PoisonResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyResistSeed - { - get => m_EnergyResistance; - set - { - m_EnergyResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalDamage { get; set; } = 100; - - [CommandProperty(AccessLevel.GameMaster)] - public int FireDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyDamage { get; set; } + public override int BasePhysicalResistance => _physicalResistanceSeed; + public override int BaseFireResistance => _fireResistSeed; + public override int BaseColdResistance => _coldResistSeed; + public override int BasePoisonResistance => _poisonResistSeed; + public override int BaseEnergyResistance => _energyResistSeed; [CommandProperty(AccessLevel.GameMaster)] public int ChaosDamage { get; set; } @@ -1146,15 +1375,12 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int DirectDamage { get; set; } - // Is immune to breath damages public virtual bool BreathImmune => false; - public virtual bool CanFlee => !m_Paragon; + public virtual bool CanFlee => !_isParagon; public DateTime EndFleeTime { get; set; } - public List Friends { get; private set; } - public virtual bool AllowNewPetFriend => Friends == null || Friends.Count < 5; public virtual Ethic EthicAllegiance => null; @@ -1206,7 +1432,6 @@ namespace Server.Mobiles public HonorContext ReceivedHonorContext { get; set; } public List MLQuests => - // Assign the quests if we don't have one, and if it is still null, return an empty list (m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList; public virtual MonsterAbility[] GetMonsterAbilities() => null; @@ -1432,7 +1657,7 @@ namespace Server.Mobiles return false; } - if (m_Team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) + if (_team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) { return true; } @@ -1550,7 +1775,7 @@ namespace Server.Mobiles var chance = Math.Clamp(700 + bonus, 220, 990); - chance -= (MaxLoyalty - m_Loyalty) * 10; + chance -= (MaxLoyalty - _loyalty) * 10; return chance / 1000.0; } @@ -1642,7 +1867,7 @@ namespace Server.Mobiles public override bool CheckPoisonImmunity(Mobile from, Poison poison) => base.CheckPoisonImmunity(from, poison) || - (m_Paragon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; + (_isParagon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; public void Unpacify() { @@ -1669,7 +1894,6 @@ namespace Server.Mobiles } int disruptThreshold; - // NPCs can use bandages too! if (!Core.AOS) { disruptThreshold = 0; @@ -1919,163 +2143,28 @@ namespace Server.Mobiles } } - public override void Serialize(IGenericWriter writer) + // Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom. + private void Deserialize(IGenericReader reader, int version) { - base.Serialize(writer); + _currentAI = (AIType)reader.ReadInt(); + _defaultAI = (AIType)reader.ReadInt(); - writer.Write(22); // version + _rangePerception = reader.ReadInt(); + _rangeFight = reader.ReadInt(); - writer.Write((int)m_CurrentAI); - writer.Write((int)m_DefaultAI); - - writer.Write(RangePerception); - writer.Write(RangeFight); - - writer.Write(m_Team); - - writer.Write(_activeSpeed); - writer.Write(_passiveSpeed); - writer.Write(_currentSpeed); - - writer.Write(m_Home.X); - writer.Write(m_Home.Y); - writer.Write(m_Home.Z); - - // Version 1 - writer.Write(RangeHome); - - // Version 2 - writer.Write((int)FightMode); - - writer.Write(_controlled); - writer.Write(m_ControlMaster); - writer.Write(ControlTarget); - writer.Write(ControlDest); - writer.Write((int)m_ControlOrder); - writer.Write(MinTameSkill); - // Removed in version 9 - // writer.Write( (double) m_dMaxTameSkill ); - writer.Write(m_bTamable); - writer.Write(_summoned); - - if (_summoned) - { - writer.WriteAnchoredTime(SummonEnd); - } - - writer.Write(ControlSlots); - - // Version 3 - writer.Write(m_Loyalty); - - // Version 4 - writer.Write(CurrentWayPoint); - - // Verison 5 - writer.Write(m_SummonMaster); - - // Version 6 - writer.Write(HitsMaxSeed); - writer.Write(StamMaxSeed); - writer.Write(ManaMaxSeed); - writer.Write(m_DamageMin); - writer.Write(m_DamageMax); - - // Version 7 - writer.Write(m_PhysicalResistance); - writer.Write(PhysicalDamage); - - writer.Write(m_FireResistance); - writer.Write(FireDamage); - - writer.Write(m_ColdResistance); - writer.Write(ColdDamage); - - writer.Write(m_PoisonResistance); - writer.Write(PoisonDamage); - - writer.Write(m_EnergyResistance); - writer.Write(EnergyDamage); - - // Version 8 - Owners.Tidy(); - writer.Write(Owners); - - // Version 10 - writer.Write(IsDeadPet); - writer.Write(m_IsBonded); - writer.Write(BondingBegin); - writer.Write(OwnerAbandonTime); - - // Version 11 - writer.Write(m_HasGeneratedLoot); - - // Version 12 - writer.Write(m_Paragon); - - var hasFriends = Friends?.Count > 0; - - // Version 13 - writer.Write(hasFriends); - - if (hasFriends) - { - Friends.Tidy(); - writer.Write(Friends); - } - - // Version 14 - writer.Write(RemoveIfUntamed); - writer.Write(RemoveStep); - - // Version 17 - if (IsStabled || Controlled && ControlMaster != null) - { - writer.Write(TimeSpan.Zero); - } - else - { - writer.Write(DeleteTimeLeft); - } - - // Version 18 - writer.Write(CorpseNameOverride); - - // Version 19 - writer.Write(HomeMap); - - // Version 22 (0 = inherit the matching think value) - writer.Write(_activeMoveSpeed); - writer.Write(_passiveMoveSpeed); - } - - public override void Deserialize(IGenericReader reader) - { - base.Deserialize(reader); - - NextReacquireTime = Core.TickCount; - - var version = reader.ReadInt(); - - m_CurrentAI = (AIType)reader.ReadInt(); - m_DefaultAI = (AIType)reader.ReadInt(); - - RangePerception = reader.ReadInt(); - RangeFight = reader.ReadInt(); - - m_Team = reader.ReadInt(); + _team = reader.ReadInt(); _activeSpeed = reader.ReadDouble(); _passiveSpeed = reader.ReadDouble(); _currentSpeed = reader.ReadDouble(); - m_Home.X = reader.ReadInt(); - m_Home.Y = reader.ReadInt(); - m_Home.Z = reader.ReadInt(); + _home.X = reader.ReadInt(); + _home.Y = reader.ReadInt(); + _home.Z = reader.ReadInt(); if (version >= 1) { - RangeHome = reader.ReadInt(); + _rangeHome = reader.ReadInt(); if (version < 20) { @@ -2096,121 +2185,121 @@ namespace Server.Mobiles } else { - RangeHome = 0; + _rangeHome = 0; } if (version >= 2) { - FightMode = (FightMode)reader.ReadInt(); + _fightMode = (FightMode)reader.ReadInt(); _controlled = reader.ReadBool(); - m_ControlMaster = reader.ReadEntity(); - ControlTarget = reader.ReadEntity(); - ControlDest = reader.ReadPoint3D(); - m_ControlOrder = (OrderType)reader.ReadInt(); + _controlMaster = reader.ReadEntity(); + _controlTarget = reader.ReadEntity(); + _controlDest = reader.ReadPoint3D(); + _controlOrder = (OrderType)reader.ReadInt(); - MinTameSkill = reader.ReadDouble(); + _minTameSkill = reader.ReadDouble(); if (version < 9) { reader.ReadDouble(); } - m_bTamable = reader.ReadBool(); + _tamable = reader.ReadBool(); _summoned = reader.ReadBool(); if (_summoned) { - SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - new UnsummonTimer(this, SummonEnd - Core.Now).Start(); + // The UnsummonTimer is restarted in AfterDeserialization. + _summonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); } - ControlSlots = reader.ReadInt(); + _controlSlots = reader.ReadInt(); } else { - FightMode = FightMode.Closest; + _fightMode = FightMode.Closest; _controlled = false; - m_ControlMaster = null; - ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlMaster = null; + _controlTarget = null; + _controlOrder = OrderType.None; } if (version >= 3) { - m_Loyalty = reader.ReadInt(); + _loyalty = reader.ReadInt(); } else { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; } if (version >= 4) { - CurrentWayPoint = reader.ReadEntity(); + _currentWayPoint = reader.ReadEntity(); } if (version >= 5) { - m_SummonMaster = reader.ReadEntity(); + _summonMaster = reader.ReadEntity(); } if (version >= 6) { - HitsMaxSeed = reader.ReadInt(); - StamMaxSeed = reader.ReadInt(); - ManaMaxSeed = reader.ReadInt(); - m_DamageMin = reader.ReadInt(); - m_DamageMax = reader.ReadInt(); + _hitsMaxSeed = reader.ReadInt(); + _stamMaxSeed = reader.ReadInt(); + _manaMaxSeed = reader.ReadInt(); + _damageMin = reader.ReadInt(); + _damageMax = reader.ReadInt(); } if (version >= 7) { - m_PhysicalResistance = reader.ReadInt(); - PhysicalDamage = reader.ReadInt(); + _physicalResistanceSeed = reader.ReadInt(); + _physicalDamage = reader.ReadInt(); - m_FireResistance = reader.ReadInt(); - FireDamage = reader.ReadInt(); + _fireResistSeed = reader.ReadInt(); + _fireDamage = reader.ReadInt(); - m_ColdResistance = reader.ReadInt(); - ColdDamage = reader.ReadInt(); + _coldResistSeed = reader.ReadInt(); + _coldDamage = reader.ReadInt(); - m_PoisonResistance = reader.ReadInt(); - PoisonDamage = reader.ReadInt(); + _poisonResistSeed = reader.ReadInt(); + _poisonDamage = reader.ReadInt(); - m_EnergyResistance = reader.ReadInt(); - EnergyDamage = reader.ReadInt(); + _energyResistSeed = reader.ReadInt(); + _energyDamage = reader.ReadInt(); } if (version >= 8) { - Owners = reader.ReadEntityList(); + _owners = reader.ReadEntityList(); } else { - Owners = new List(); + _owners = new List(); } if (version >= 10) { - IsDeadPet = reader.ReadBool(); - m_IsBonded = reader.ReadBool(); - BondingBegin = reader.ReadDateTime(); - OwnerAbandonTime = reader.ReadDateTime(); + _isDeadPet = reader.ReadBool(); + _isBonded = reader.ReadBool(); + _bondingBegin = reader.ReadDateTime(); + _ownerAbandonTime = reader.ReadDateTime(); } - m_HasGeneratedLoot = version < 11 || reader.ReadBool(); + _hasGeneratedLoot = version < 11 || reader.ReadBool(); - m_Paragon = version >= 12 && reader.ReadBool(); + _isParagon = version >= 12 && reader.ReadBool(); if (version >= 13 && reader.ReadBool()) { - Friends = reader.ReadEntityList(); + _friends = reader.ReadEntityList(); } - else if (version < 13 && m_ControlOrder >= OrderType.Unfriend) + else if (version < 13 && _controlOrder >= OrderType.Unfriend) { - ++m_ControlOrder; + ++_controlOrder; } if (version < 16 && Loyalty != MaxLoyalty) @@ -2220,8 +2309,8 @@ namespace Server.Mobiles if (version >= 14) { - RemoveIfUntamed = reader.ReadBool(); - RemoveStep = reader.ReadInt(); + _removeIfUntamed = reader.ReadBool(); + _removeStep = reader.ReadInt(); } var deleteTime = TimeSpan.Zero; @@ -2238,18 +2327,18 @@ namespace Server.Mobiles deleteTime = TimeSpan.FromDays(3.0); } - m_DeleteTimer = new DeleteTimer(this, deleteTime); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, deleteTime); + _pendingDeleteTimer.Start(); } if (version >= 18) { - CorpseNameOverride = reader.ReadString(); + _corpseNameOverride = reader.ReadString(); } if (version >= 19) { - HomeMap = reader.ReadMap(); + _homeMap = reader.ReadMap(); } if (version >= 22) @@ -2262,25 +2351,61 @@ namespace Server.Mobiles MigrateMoveSpeeds(); } - if (version <= 14 && m_Paragon && Hue == 0x31) + if (version <= 14 && _isParagon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. } + } + + [AfterDeserialization] + private void AfterDeserialization() + { + NextReacquireTime = Core.TickCount; + + if (_activeSpeed <= 0 || _passiveSpeed <= 0) + { + if (!_loggedMissingSpeeds) + { + _loggedMissingSpeeds = true; + logger.Error( + "{Type} loaded without speeds - is Data/npc-speeds.json missing or changed? Pacing at {Active}/{Passive}.", + GetType(), + FallbackActiveSpeed, + FallbackPassiveSpeed + ); + } + + _activeSpeed = FallbackActiveSpeed; + _passiveSpeed = FallbackPassiveSpeed; + _currentSpeed = _passiveSpeed; + } if (Core.AOS && NameHue == 0x35) { NameHue = -1; } + if (_summoned) + { + new UnsummonTimer(this, _summonEnd - Core.Now).Start(); + } + + // An abandoned pet with no persisted countdown still despawns. + if (_pendingDeleteTimer == null && LastOwner != null && !_controlled && !IsStabled) + { + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + } + CheckStatTimers(); - ChangeAIType(m_CurrentAI); + ChangeAIType(_currentAI); AddFollowers(); if (IsAnimatedDead) { - AnimateDeadSpell.Register(m_SummonMaster, this); + AnimateDeadSpell.Register(_summonMaster, this); } } @@ -2335,8 +2460,7 @@ namespace Server.Mobiles return true; } - // Note: Yes, this happens for all questers (regardless of type, e.g. escorts), - // even if they can't offer you anything at the moment + // Happens for all questers, even those with nothing to offer right now. if (MLQuestSystem.Enabled && CanGiveMLQuest && from is PlayerMobile mobile) { // You need to mark your quest items so I don't take the wrong object. Then speak to me. @@ -2367,7 +2491,7 @@ namespace Server.Mobiles AIType.AI_Vendor => new VendorAI(this), AIType.AI_Mage => new MageAI(this), AIType.AI_Predator => - // m_AI = new PredatorAI(this); + //TODO Implement PredatorAI new MeleeAI(this), AIType.AI_Thief => new ThiefAI(this), _ => null @@ -2387,7 +2511,7 @@ namespace Server.Mobiles public void RemoveFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers -= Math.Min(ControlSlots, master.Followers); @@ -2401,7 +2525,7 @@ namespace Server.Mobiles public void AddFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers += ControlSlots; @@ -2447,7 +2571,7 @@ namespace Server.Mobiles public virtual void OnGaveMeleeAttack(Mobile defender, int damage) { - var p = m_Paragon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; + var p = _isParagon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; if (p != null && HitPoisonChance >= Utility.RandomDouble()) { @@ -2476,17 +2600,13 @@ namespace Server.Mobiles AIObject = null; } - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } + StopPendingDeleteTimer(); FocusMob = null; if (IsAnimatedDead) { - AnimateDeadSpell.Unregister(m_SummonMaster, this); + AnimateDeadSpell.Unregister(_summonMaster, this); } if (Summoned && SummonMaster != null) @@ -2505,13 +2625,6 @@ namespace Server.Mobiles base.OnAfterDelete(); } - /* - * This function can be overridden.. so a "Strongest" mobile, can have a different definition depending - * on who check for value - * -Could add a FightMode.Preferred - * - */ - public virtual double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) { if (bPlayerOnly && !m.Player) @@ -2527,8 +2640,7 @@ namespace Server.Mobiles }; } - // Turn, - for left, + for right - // Basic for now, needs work + // Turn: negative = left, positive = right. public virtual void Turn(int iTurnSteps) { var v = (int)Direction; @@ -2545,7 +2657,7 @@ namespace Server.Mobiles public bool IsHurt() => Hits != HitsMax; - public double GetHomeDistance() => this.GetDistanceToSqrt(m_Home); + public double GetHomeDistance() => this.GetDistanceToSqrt(_home); public virtual int GetTeamSize(int iRange) { @@ -2572,7 +2684,7 @@ namespace Server.Mobiles aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } - var ct = m_ControlOrder; + var ct = _controlOrder; if (AIObject != null) { @@ -2646,7 +2758,7 @@ namespace Server.Mobiles AIObject?.GetContextMenuEntries(from, ref list); } - if (m_bTamable && !_controlled && from.Alive) + if (_tamable && !_controlled && from.Alive) { list.Add(new TameEntry(from.Female ? AllowFemaleTamer : AllowMaleTamer)); } @@ -2697,7 +2809,7 @@ namespace Server.Mobiles } public override bool IsHarmfulCriminal(Mobile target) => - (!Controlled || target != m_ControlMaster) && (!Summoned || target != m_SummonMaster) && + (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) && (target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) && (target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target); @@ -2707,13 +2819,13 @@ namespace Server.Mobiles if (Controlled || Summoned) { - if (m_ControlMaster?.Player == true) + if (_controlMaster?.Player == true) { - m_ControlMaster.CriminalAction(false); + _controlMaster.CriminalAction(false); } - else if (m_SummonMaster?.Player == true) + else if (_summonMaster?.Player == true) { - m_SummonMaster.CriminalAction(false); + _summonMaster.CriminalAction(false); } } } @@ -2722,7 +2834,7 @@ namespace Server.Mobiles { base.DoHarmful(target, indirect); - if (target == this || target == m_ControlMaster || target == m_SummonMaster || !Controlled && !Summoned) + if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned) { return; } @@ -2772,12 +2884,11 @@ namespace Server.Mobiles { if (Combatant != null) { - return false; // in combat.. not idling + return false; // in combat, not idling } if (m_IdleReleaseTime > DateTime.MinValue) { - // idling... if (Core.Now >= m_IdleReleaseTime) { m_IdleReleaseTime = DateTime.MinValue; @@ -2789,7 +2900,7 @@ namespace Server.Mobiles if (Utility.Random(100) < 95) { - return false; // not idling, but don't want to enter idle state + return false; // chose not to enter the idle state } var idleSeconds = Utility.RandomMinMax(NPCSpeeds.MinIdleSeconds, NPCSpeeds.MaxIdleSeconds); @@ -2829,12 +2940,6 @@ namespace Server.Mobiles return true; // entered idle state } - /* - this way, due to the huge number of locations this will have to be changed - Perhaps we can change this in the future when fixing game play is not the - major issue. - */ - public virtual void CheckedAnimate(int action, int frameCount, int repeatCount, bool forward, bool repeat, int delay) { if (!Mounted) @@ -2897,7 +3002,7 @@ namespace Server.Mobiles SpeechType?.OnMovement(this, m, oldLocation); - /* Begin notice sound */ + // Notice sound if ((!m.Hidden || m.AccessLevel == AccessLevel.Player) && m.Player && FightMode != FightMode.Aggressor && FightMode != FightMode.None && Combatant == null && !Controlled && !Summoned && !BardPacified && InRange(m.Location, 18) && !InRange(oldLocation, 18)) @@ -2909,7 +3014,6 @@ namespace Server.Mobiles PlaySound(GetAngerSound()); } - /* End notice sound */ if (MLQuestSystem.Enabled && CanShout && m is PlayerMobile mobile) { @@ -2983,7 +3087,7 @@ namespace Server.Mobiles list.Add(TotalWeight == 1 ? 1072788 : 1072789, TotalWeight); // Weight: ~1_WEIGHT~ stones } - if (m_ControlOrder == OrderType.Guard) + if (_controlOrder == OrderType.Guard) { list.Add(1080078); // guarding } @@ -2995,7 +3099,7 @@ namespace Server.Mobiles } else if (Controlled && Commandable) { - // Intentional difference (showing ONLY bonded when bonded instead of bonded & tame) + // Deliberate: show only (bonded), never (bonded) and (tame) together. if (IsBonded) { list.Add(1049608); // (bonded) @@ -3057,7 +3161,7 @@ namespace Server.Mobiles { if (treasureLevel >= 0) { - if (m_Paragon && Paragon.ChestChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChestChance > Utility.RandomDouble()) { PackItem(new ParagonChest(Name, treasureLevel)); } @@ -3067,7 +3171,7 @@ namespace Server.Mobiles } } - if (m_Paragon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) { switch (Utility.Random(4)) { @@ -3095,9 +3199,10 @@ namespace Server.Mobiles } } - if (!Summoned && !NoKillAwards && !m_HasGeneratedLoot) + if (!Summoned && !NoKillAwards && !_hasGeneratedLoot) { - m_HasGeneratedLoot = true; + _hasGeneratedLoot = true; + this.MarkDirty(); GenerateLoot(false); } @@ -3289,7 +3394,7 @@ namespace Server.Mobiles MondainsLegacy.GiveArtifactTo(mob); } } - else if (m_Paragon) + else if (_isParagon) { if (Paragon.CheckArtifactChance(mob, this)) { @@ -3298,9 +3403,6 @@ namespace Server.Mobiles } } - [GeneratedEvent(nameof(CreatureDeathEvent))] - public static partial void CreatureDeathEvent(BaseCreature bc); - public override void OnDeath(Container c) { if (IsBonded) @@ -3323,7 +3425,6 @@ namespace Server.Mobiles ProcessDelta(); SendIncomingPacket(); - // TODO: This can be done in Parallel if there are lots of them. var aggressors = Aggressors; for (var i = 0; i < aggressors.Count; ++i) @@ -3363,7 +3464,7 @@ namespace Server.Mobiles OwnerAbandonTime = DateTime.MinValue; } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); CheckStatTimers(); return; @@ -3397,7 +3498,6 @@ namespace Server.Mobiles if (ds.m_Mobile == killer) { - // If the titles system gets feature flagged, it will be supported titles.Add(ds.m_Mobile); fame.Add(totalFame); karma.Add(totalKarma); @@ -3493,17 +3593,14 @@ namespace Server.Mobiles c.Delete(); } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); } - [GeneratedEvent(nameof(CreatureDeletedEvent))] - public static partial void CreatureDeletedEvent(BaseCreature bc); - public override void OnDelete() { - CreatureDeletedEvent(this); + CreatureEvents.CreatureDeletedEvent(this); - var m = m_ControlMaster; + var m = _controlMaster; SetControlMaster(null); SummonMaster = null; @@ -3576,12 +3673,7 @@ namespace Server.Mobiles ControlTarget = null; ControlOrder = OrderType.Come; - - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } + StopPendingDeleteTimer(); } Guild = null; @@ -3774,7 +3866,7 @@ namespace Server.Mobiles { // *rummages through a corpse and takes an item* PublicOverheadMessage(MessageType.Emote, 0x3B2, 1008086); - // TODO: Instancing of Rummaged stuff. + //TODO Instance rummaged loot return true; } } @@ -3795,14 +3887,14 @@ namespace Server.Mobiles return BardMaster; } - if (_controlled && m_ControlMaster != null) + if (_controlled && _controlMaster != null) { - return m_ControlMaster; + return _controlMaster; } - if (_summoned && m_SummonMaster != null) + if (_summoned && _summonMaster != null) { - return m_SummonMaster; + return _summonMaster; } return base.GetDamageMaster(damagee); @@ -4074,19 +4166,13 @@ namespace Server.Mobiles if (this is not BaseEscortable && !Summoned && !Deleted && !IsStabled) { StopDeleteTimer(); - m_DeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + this.MarkDirty(); } } - public void StopDeleteTimer() - { - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } - } + public void StopDeleteTimer() => StopPendingDeleteTimer(); public void SpillAcid(int amount) { @@ -4120,11 +4206,7 @@ namespace Server.Mobiles } } - /* - Solen Style, override me for other mobiles/items: - kappa+acidslime, grizzles+whatever, etc. - */ - + // Solen-style acid; override for other harmful drops (kappa slime, etc.). public virtual Item NewHarmfulItem() => new Acid(TimeSpan.FromSeconds(10), 30, 30); public virtual void StopFlee() @@ -4165,7 +4247,7 @@ namespace Server.Mobiles public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m); public virtual bool IsFriend(Mobile m) => - OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && m_Team == c.m_Team + OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team && (_summoned || _controlled) == (c._summoned || c._controlled); public virtual Allegiance GetFactionAllegiance(Mobile mob) @@ -4357,16 +4439,17 @@ namespace Server.Mobiles if (Core.SE) { - m_Loyalty = MaxLoyalty; + _loyalty = MaxLoyalty; + this.MarkDirty(); } - else if (m_Loyalty < MaxLoyalty) + else if (_loyalty < MaxLoyalty) { - // Calculate the loyalty increase var loyaltyIncrease = Utility.CoinFlips(amount, MaxLoyaltyIncrease) * 10; - if (loyaltyIncrease > 0) // Only update if there's an actual increase + if (loyaltyIncrease > 0) { - m_Loyalty = Math.Min(MaxLoyalty, m_Loyalty + loyaltyIncrease); + _loyalty = Math.Min(MaxLoyalty, _loyalty + loyaltyIncrease); + this.MarkDirty(); SayTo(from, 502060); // Your pet looks happier. } } @@ -4382,7 +4465,7 @@ namespace Server.Mobiles if (IsBondable && !IsBonded) { - var master = m_ControlMaster; + var master = _controlMaster; if (master != null && master == from) // So friends can't start the bonding process { @@ -4569,7 +4652,6 @@ namespace Server.Mobiles } } - /* Sanity check */ if (baseToSet > theirSkill.CapFixedPoint || m.Skills.Total - theirSkill.BaseFixedPoint + baseToSet > m.Skills.Cap) { @@ -4680,6 +4762,7 @@ namespace Server.Mobiles { _activeMoveSpeed = 0; _passiveMoveSpeed = 0; + this.MarkDirty(); } /// @@ -4697,6 +4780,8 @@ namespace Server.Mobiles { _passiveMoveSpeed *= scalar; } + + this.MarkDirty(); } /// @@ -4726,6 +4811,8 @@ namespace Server.Mobiles { _passiveMoveSpeed = passiveMoveSpeed; } + + this.MarkDirty(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -4734,16 +4821,13 @@ namespace Server.Mobiles [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToPassive() => CurrentSpeed = PassiveSpeed; - public void SetDamage(int val) - { - m_DamageMin = val; - m_DamageMax = val; - } + public void SetDamage(int val) => SetDamage(val, val); public void SetDamage(int min, int max) { - m_DamageMin = min; - m_DamageMax = max; + _damageMin = min; + _damageMax = max; + this.MarkDirty(); } public void SetHits(int val) @@ -4877,31 +4961,32 @@ namespace Server.Mobiles { case ResistanceType.Physical: { - m_PhysicalResistance = val; + _physicalResistanceSeed = val; break; } case ResistanceType.Fire: { - m_FireResistance = val; + _fireResistSeed = val; break; } case ResistanceType.Cold: { - m_ColdResistance = val; + _coldResistSeed = val; break; } case ResistanceType.Poison: { - m_PoisonResistance = val; + _poisonResistSeed = val; break; } case ResistanceType.Energy: { - m_EnergyResistance = val; + _energyResistSeed = val; break; } } + this.MarkDirty(); UpdateResistances(); } @@ -5036,20 +5121,39 @@ namespace Server.Mobiles // If this needs to be serialized, recommend creating a hash or registry id. Don't serialize strings. public virtual SpeedLevel SpeedClass => SpeedLevel.None; + // Cached: the speed SaveFlags consult this on every save and elided load. + private NPCSpeeds.SpeedClassEntry _speedEntry; + + private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(this); + + // Never throws: the speed SaveFlags call this on every save and elided load. Without a + // table entry the serialized speeds stand; only the constructor refuses. public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) { - NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); + var entry = SpeedEntry; + + if (entry == null) + { + activeSpeed = _activeSpeed; + passiveSpeed = _passiveSpeed; + return; + } + + activeSpeed = entry.ActiveSpeed; + passiveSpeed = entry.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates an unloaded table. public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) { - NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + var entry = SpeedEntry; + + activeMoveSpeed = entry?.ActiveMoveSpeed ?? 0; + passiveMoveSpeed = entry?.PassiveMoveSpeed ?? 0; } - // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds - // still match what it would spawn with today was never hand-tuned: adopt today's - // move values so existing worlds (and pets) pick up npc-speeds pacing without a - // respawn. Tuned creatures keep movement inheriting their think clock. + // Pre-v22 saves have no movement clock: untuned creatures adopt the table's move + // values, hand-tuned ones keep inheriting. internal void MigrateMoveSpeeds() { GetSpeeds(out var activeSpeed, out var passiveSpeed); @@ -5098,7 +5202,7 @@ namespace Server.Mobiles GenerateLoot(); - if (m_Paragon) + if (_isParagon) { if (Fame < 1250) { @@ -5498,8 +5602,6 @@ namespace Server.Mobiles var onSelf = patient == this; - // DoBeneficial( patient ); - RevealingAction(); if (!onSelf) @@ -5542,7 +5644,7 @@ namespace Server.Mobiles { patient.SendLocalizedMessage(1010059); // You have been cured of all poisons. - CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); // TODO: Verify formula + CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); //TODO Verify formula CheckSkill(SkillName.Anatomy, 0.0, 100.0); } } @@ -5741,7 +5843,6 @@ namespace Server.Mobiles using var toRelease = PooledRefQueue.Create(); - // added array for wild creatures in house regions to be removed using var toRemove = PooledRefQueue.Create(); foreach (var m in World.Mobiles.Values) @@ -5799,7 +5900,7 @@ namespace Server.Mobiles } } - // added lines to check if a wild creature in a house region has to be removed or not + // Wild creatures squatting in houses are removed outright. if (!c.Controlled && !c.IsStabled && (c.Region.IsPartOf() && c.CanBeDamaged() || c.RemoveIfUntamed && c.Spawner == null)) { @@ -5821,12 +5922,13 @@ namespace Server.Mobiles var c = toRelease.Dequeue(); c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! - c.Loyalty = BaseCreature.MaxLoyalty; // Wonderfully Happy + c.Loyalty = BaseCreature.MaxLoyalty; c.IsBonded = false; c.BondingBegin = DateTime.MinValue; c.OwnerAbandonTime = DateTime.MinValue; c.ControlTarget = null; - // This will prevent no release of creatures left alone with AI disabled (and consequent bug of Followers) + // Release directly: a creature left alone with its AI disabled would + // otherwise never release and permanently hold its owner's follower slots. c.AIObject.DoOrderRelease(); c.DropBackpack(); } diff --git a/Projects/UOContent/Mobiles/CreatureEvents.cs b/Projects/UOContent/Mobiles/CreatureEvents.cs new file mode 100644 index 000000000..848e9de2c --- /dev/null +++ b/Projects/UOContent/Mobiles/CreatureEvents.cs @@ -0,0 +1,13 @@ +using ModernUO.CodeGeneratedEvents; + +namespace Server.Mobiles; + +// Hosts BaseCreature's generated events: two generators cannot both emit [GeneratedCode] on one type (CS0579). +public static partial class CreatureEvents +{ + [GeneratedEvent(nameof(CreatureDeathEvent))] + public static partial void CreatureDeathEvent(BaseCreature bc); + + [GeneratedEvent(nameof(CreatureDeletedEvent))] + public static partial void CreatureDeletedEvent(BaseCreature bc); +} diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs index 178436fb9..fca41ec25 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs @@ -154,7 +154,7 @@ namespace Server.Mobiles public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m); [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] - [OnEvent(nameof(CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] public static void StopEffect(Mobile m, bool message = false) { if (m_Table.Remove(m, out var timer)) diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index 8f5e908bb..44489ba6b 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -26,32 +26,16 @@ public static class NPCSpeeds public static int MinIdleSeconds { get; private set; } public static int MaxIdleSeconds { get; private set; } - public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed) + // Null when the table is unloaded (test fixtures). Immutable after Configure, so creatures cache it. + public static SpeedClassEntry FindEntry(BaseCreature bc) { if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && !_speedsByType.TryGetValue(bc.GetType(), out sp)) { - sp = _speedsByLevel[SpeedLevel.Medium]; + _speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp); } - activeSpeed = sp.ActiveSpeed; - passiveSpeed = sp.PassiveSpeed; - } - - // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. - public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) - { - if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && - !_speedsByType.TryGetValue(bc.GetType(), out sp) && - !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) - { - activeMoveSpeed = 0; - passiveMoveSpeed = 0; - return; - } - - activeMoveSpeed = sp.ActiveMoveSpeed; - passiveMoveSpeed = sp.PassiveMoveSpeed; + return sp; } public static void RegisterSpeed(SpeedClassEntry entry) diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index e37431ddd..e930af423 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -5,7 +5,6 @@ using System.IO; using System.Runtime.InteropServices; using System.Text.Json.Serialization; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Json; using Server.Mobiles; diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index 487eda693..27ba888cf 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Server.Collections; using Server.Engines.PartySystem; using Server.Factions; using Server.Guilds; diff --git a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs index c0f3c3ed0..578bdc482 100644 --- a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs +++ b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs @@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell // shared timer from either the caster or the target key, so a single call per mobile is enough. [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void OnCurseEnds(Mobile m) => RemoveCurse(m); private class ExpireTimer : Timer diff --git a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs index 585d37284..6a5d4850a 100644 --- a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs +++ b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs @@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving Caster.Target = new SpellTarget(this, TargetFlags.Beneficial); } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] public static void OnDeathEvent(Mobile m) { diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 6205ec2d4..f1df32d42 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs (combat decisions, target acquisition, spell timing). - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per step. Inherits the matching think value until overridden, so a creature configured with - only think speeds behaves as one clock. Any value is legal — steps are scheduled - independently of think ticks, so the two need not divide evenly. + only think speeds behaves as one clock. The properties read the raw override (`0` = + inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are + scheduled independently of think ticks, so the two need not divide evenly. Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: From ab738d90af4cbaf4060942725e71e44e65fb1d60 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:12:29 -0700 Subject: [PATCH 05/21] fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613) Pet and summon bugs found by the master-reference audit for #2592 (comments there have the full inventory). Independent of delta saves. ## Releasing a pet never finished The Release order ran two half-releases that never met: - The order handler cleared the master. That set `Controlled` to false, so `Obey` never ran again and the think-side `DoOrderRelease` (re-home, three-day delete timer, backpack drop) was dead code: a released pet kept its pack and never despawned. - The loyalty drain called `DoOrderRelease` directly, so a pet whose loyalty hit zero got the countdown and dropped its pack but kept its master and its owner's follower slots, the opposite of the code comment's intent. `DoOrderRelease` is now the whole release (targets, bonding, `SetControlMaster(null)`, re-home, delete or countdown, pack drop) and runs once, synchronously, from the handler or the loyalty drain. Summons still die on release, as before. Two tests pin both entry points: master cleared, follower slots returned, countdown running, home anchored. This is the one place the `PetOrders` / `PetOrderHandlers` split bit; the wider audit of that duplication is a separate task. ## Summon master follows the pet Transfer, stable claim, GM "obey" and Ball of Summoning copied `SummonMaster` only when `Summoned`, so a talisman summon (`Summoned` is false, `SummonMaster` set) kept its original summoner after a transfer: two different masters on one creature, with the original summoner's area spells still exempting it. They now mirror the summon master whenever it is set. Jail stabling cleared only `ControlMaster`, so a jailed talisman summon kept charging the summoner's follower slots; it now clears both, like the stable master and auto-stable already do. ## Small ones - The faction horse breeder set `Controlled`/`ControlMaster` directly; it now goes through `SetControlMaster` and tells the buyer why it refused (1049607) instead of silently deleting the horse. - `Notoriety` dereferenced `SummonMaster` on a summoned creature without a null check. ## Tests UOContent.Tests 778 green (two new). --- .../Tests/Mobiles/AI/PetOrderTests.cs | 32 +++++++++++++++++++ .../Factions/Gumps/HorseBreederGump.cs | 6 ++-- .../Special/Solen Items/BallOfSummoning.cs | 2 +- Projects/UOContent/Misc/Notoriety.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/OnSpeech.cs | 2 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 9 +----- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 15 +++++++++ .../Mobiles/AI/BaseAI/TransferItem.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 7 ---- Projects/UOContent/Mobiles/PlayerMobile.cs | 2 +- .../Mobiles/Vendors/NPC/AnimalTrainer.cs | 2 +- .../Systems/JailSystem/JailSystem.cs | 1 + 12 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index e0fe22a39..e5d7760ed 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -176,6 +176,38 @@ public class PetOrderTests : IDisposable Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts } + [Fact] + public void ReleaseOrder_ClearsTheMaster_AndStartsTheDeleteCountdown() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.IsBonded = true; + var followers = master.Followers; + + pet.ControlOrder = OrderType.Release; + + Assert.False(pet.Controlled); + Assert.Null(pet.ControlMaster); + Assert.False(pet.IsBonded); + Assert.Equal(followers - pet.ControlSlots, master.Followers); + Assert.True(pet.PendingDeleteTimer?.Running); + Assert.Equal(pet.Location, pet.Home); + } + + [Fact] + public void LoyaltyRelease_ClearsTheMaster_AndStartsTheDeleteCountdown() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var followers = master.Followers; + + // What the loyalty drain calls when loyalty reaches zero. + pet.AIObject.DoOrderRelease(); + + Assert.False(pet.Controlled); + Assert.Null(pet.ControlMaster); + Assert.Equal(followers - pet.ControlSlots, master.Followers); + Assert.True(pet.PendingDeleteTimer?.Running); + } + [Fact] public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation() { diff --git a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs index 7cd4edc92..8e8bf9308 100644 --- a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs +++ b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs @@ -61,7 +61,7 @@ public class HorseBreederGump : FactionGump if (m_From.Followers + horse.ControlSlots > m_From.FollowersMax) { - // TODO: Message? + m_From.SendLocalizedMessage(1049607); // You have too many followers to control that creature. horse.Delete(); } else @@ -79,9 +79,7 @@ public class HorseBreederGump : FactionGump else if (pack.ConsumeTotal(typeof(Silver), FactionWarHorse.SilverPrice) && pack.ConsumeTotal(typeof(Gold), FactionWarHorse.GoldPrice)) { - horse.Controlled = true; - horse.ControlMaster = m_From; - + horse.SetControlMaster(m_From); horse.ControlOrder = OrderType.Follow; horse.ControlTarget = m_From; diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index 070a802b1..bf0953041 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -228,7 +228,7 @@ public partial class BallOfSummoning : Item, TranslocationItem { pet.SetControlMaster(from); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = from; } diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs index cb9b1ddcb..4416b09b5 100644 --- a/Projects/UOContent/Misc/Notoriety.cs +++ b/Projects/UOContent/Misc/Notoriety.cs @@ -228,7 +228,7 @@ namespace Server.Misc } if (bcTarg?.Controlled == true - || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster.Player) + || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster?.Player == true) { return false; // Cannot harm other controlled mobiles from players } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index 8ffd39b42..ce9af5bb7 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -448,7 +448,7 @@ public abstract partial class BaseAI { Mobile.SetControlMaster(e.Mobile); - if (Mobile.Summoned) + if (Mobile.SummonMaster != null) { Mobile.SummonMaster = e.Mobile; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 862c78649..a132bc896 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -261,15 +261,8 @@ public abstract partial class BaseAI } _commandIssuer?.RevealingAction(); - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; Mobile.PlaySound(Mobile.GetIdleSound()); - Mobile.BondingBegin = DateTime.MinValue; - Mobile.OwnerAbandonTime = DateTime.MinValue; - Mobile.IsBonded = false; - Mobile.SetControlMaster(null); + DoOrderRelease(); _commandIssuer = null; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e6c48850d..e09424328 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -13,6 +13,8 @@ * along with this program. If not, see . * ************************************************************************/ +using System; + namespace Server.Mobiles; public abstract partial class BaseAI @@ -457,10 +459,23 @@ public abstract partial class BaseAI return best; } + /// + /// The whole release: the master is cleared here, so this runs once, synchronously, from + /// the Release order handler or the loyalty drain, never from Obey. + /// public virtual bool DoOrderRelease() { DebugSay("I have been released to the wild."); + Mobile.ControlTarget = null; + Mobile.FocusMob = null; + Mobile.Warmode = false; + Mobile.Combatant = null; + Mobile.BondingBegin = DateTime.MinValue; + Mobile.OwnerAbandonTime = DateTime.MinValue; + Mobile.IsBonded = false; + Mobile.SetControlMaster(null); + var spawner = Mobile.Spawner; if (spawner != null) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 9efad5a5e..331df533c 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -156,7 +156,7 @@ internal sealed partial class TransferItem : Item private void TransferPetOwnership(Mobile from, Mobile to) { - if (_creature.Summoned) + if (_creature.SummonMaster != null) { _creature.SummonMaster = to; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 5ea68cb13..f8dba7bf1 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -5923,14 +5923,7 @@ namespace Server.Mobiles c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! c.Loyalty = BaseCreature.MaxLoyalty; - c.IsBonded = false; - c.BondingBegin = DateTime.MinValue; - c.OwnerAbandonTime = DateTime.MinValue; - c.ControlTarget = null; - // Release directly: a creature left alone with its AI disabled would - // otherwise never release and permanently hold its owner's follower slots. c.AIObject.DoOrderRelease(); - c.DropBackpack(); } while (toRemove.Count > 0) diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index d0b1b2c7a..39be0e10a 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3602,7 +3602,7 @@ namespace Server.Mobiles { pet.SetControlMaster(this); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = this; } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs index 847209869..b3fb4d721 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs @@ -357,7 +357,7 @@ namespace Server.Mobiles { pet.SetControlMaster(from); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = from; } diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs index 8802d0487..8da7319c9 100644 --- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -144,6 +144,7 @@ public class JailSystem : GenericPersistence bc.Internalize(); bc.SetControlMaster(null); + bc.SummonMaster = null; bc.IsStabled = true; bc.StabledBy = from; From 93e46a88b23c23cbac67f8a3ed3e8a1c951abb71 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:32:12 -0700 Subject: [PATCH 06/21] fix: OPL revision hash collided on reordered and repeated properties (#2615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug `ObjectPropertyList.AddHash` folded each cliloc and a Marvin hash of each argument together with XOR, which is both commutative and self-inverse. Any value mixed in an even number of times cancelled outright, so two properties sharing one argument hashed identically no matter what that argument was: ``` _hash = 31659021 AddHash(1063752); AddHash(hash("10")) AddHash(1063737); AddHash(hash("10")) // the argument cancels here AddHash(1063740) // -> 32714560, for ANY argument ``` The 10% and 5% variants of the same item therefore produced the same revision. The client caches the tooltip by revision and only re-requests when it changes, so it kept rendering the stale percentage. Same root cause, three more shapes: | | old | new | |---|---|---| | Two properties sharing an argument | collides | distinct | | Properties reordered | collides | distinct | | Two properties trading arguments | collides | distinct | | Argument-less property added twice | cancels to 0 | distinct | ## The fix Hash the finished property block in `Terminate()` instead of accumulating per property. Those bytes — cliloc, length prefix and UTF-16 text, in emission order — are the exact content the client renders, so anything that changes the tooltip changes the hash. The length prefix also removes the concatenation ambiguity the old scheme had. `AddHash` and both `string.GetHashCode` calls are gone. ## Why 26 bits, and why not 64 The client never recomputes the hash — it stores what we send in 0xD6 and compares it for equality against the 0xDC revision (`ObjectPropertiesListManager.IsRevisionEquals`). So the algorithm is ours to choose, but the width is not: - Both packets carry a **4-byte** revision, so 64 bits is not available. A wider internal value would be worse than useless: the server would see a change and send a 0xDC whose truncated 32 bits are identical, and the client still wouldn't refresh. - `Terminate` writes the bare hash into 0xD6 while `SendOPLInfo` writes `Hash` with bit 30 set. The client recovers one from the other by masking off `0x40000000`, which only holds while the hash stays below that bit. Hence 26 bits, unchanged from before. - `Hash => 0x40000000 + _hash` keeps the revision non-zero; the client parks 0 as its "nothing cached" sentinel. ## Collision behaviour Enumerated real small-OPL spaces and counted 26-bit collisions against the birthday expectation for a uniform hash: | Scenario | block | n | collisions | expected | |---|---|---|---|---| | 1 cliloc, no arg (every cliloc 1.0M–3.2M) | 6 B | 2,200,000 | 35,591 | ~36,061 | | 1 cliloc + numeric arg 0–199,999 | 8–12 B | 200,000 | 300 | ~298 | | 2 clilocs, both with numeric args | 12–24 B | 202,500 | 288 | ~306 | | 1 cliloc + 1-char arg | 8 B | 4,000,000 | 116,291 | ~119,209 | Every case lands on the random-model line, and per-bit P(1) across all 26 bits is 0.4962–0.5024 — XXH3's short-input paths still avalanche fully, so a 6-byte block behaves like a uniform 26-bit draw. Masking the low 26 bits versus folding all 64 down was a wash (35,591 vs 35,558). The metric that matters is narrower, since the client compares a revision only against the previous revision **for the same serial**: 2^-26 = 1.5e-8 per genuine tooltip change. Consecutive-value transitions (charges 50 -> 49) collided 0 times in 200,000. Row 3 is the old scheme quantified: it collapsed 202,500 inputs into 100,954 distinct hashes, a 50% collision rate — systematic, not probabilistic. One honest regression: in row 1 the old scheme had zero collisions, because for a single argument-less cliloc under 2^26 the hash was the identity function. An item whose whole tooltip is one argument-less cliloc changing to a different one goes from never colliding to 1.5e-8. ## Performance Cheaper, not just correct — one xxHash3 pass replaces a Marvin hash per string property over those same bytes. A 12-property, 302-byte tooltip, steady state: ``` old (XOR + Marvin per property) 70.5 ns xxHash3 (streaming, HashUtility) 27.0 ns ``` `XxHash3.HashToUInt64` one-shot is a further ~7ns faster and produces byte-identical output, but taking it would mean editing `HashUtility.ComputeHash64`, whose values are baked into save files via `AssemblyHandler.GetTypeHash`. Not worth it. ## Second commit: plant old-client property list Found while tracing the `Hash` read sites. `PlantItem.OldClientPropertyList` called `InitializePropertyList` on every access instead of only when the list was null, and without a `Reset`. Every read appended another copy of every property to the same buffer. `SendOPLPacketTo` and `SendPropertiesTo` both go through the getter, so a pre-7.0.12 client looking at a plant grew the buffer without bound and moved the revision on reads alone. Now builds once, matching `Item.PropertyList`. `InvalidateProperties` already `Reset`s before rebuilding. ## Testing `Server.Tests` 869 passed, `UOContent.Tests` 779 passed. New regression tests, each confirmed failing against the old code first: - `RepeatedArgument_DoesNotCancelOut` — the reported case - `PropertyOrder_ChangesHash`, `SwappedArguments_ChangeHash`, `DuplicateProperty_ChangesHash` - `ShortNumericArguments_ConsecutiveValuesDiffer`, `SmallPropertyBlocks_StayWellDistributed` — short-input avalanche, bounded loosely enough to hold for any seed - `Hash_StaysWithinTheRevisionMask`, `EmptyList_IsNonZeroAndDistinctFromPopulated` — wire constraints - `PlantItemPropertyListTests.OldClientPropertyList_BuildsOnceAndIsStableAcrossReads` --- .../ObjectPropertyListHashTests.cs | 147 ++++++++++++++++++ .../Server/PropertyList/ObjectPropertyList.cs | 28 ++-- .../Plants/PlantItemPropertyListTests.cs | 36 +++++ .../UOContent/Engines/Plants/PlantItem.cs | 10 +- 4 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs new file mode 100644 index 000000000..19a8704b7 --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using Server; +using Xunit; + +namespace Server.Tests; + +public class ObjectPropertyListHashTests +{ + private static int BuildHash(params (int cliloc, string arg)[] properties) + { + var opl = new ObjectPropertyList(null); + + foreach (var (cliloc, arg) in properties) + { + if (arg == null) + { + opl.Add(cliloc); + } + else + { + opl.Add(cliloc, arg.AsSpan()); + } + } + + opl.Terminate(); + return opl.Hash; + } + + // Two properties sharing an argument: the XOR fold mixed it in twice and cancelled it, so + // "10%" and "5%" produced the same revision and the client kept the stale tooltip. + [Fact] + public void RepeatedArgument_DoesNotCancelOut() + { + var ten = BuildHash( + (1063752, "10"), + (1063737, "10"), + (1063740, null) + ); + + var five = BuildHash( + (1063752, "5"), + (1063737, "5"), + (1063740, null) + ); + + Assert.NotEqual(ten, five); + } + + // XOR is self-inverse: any value mixed in an even number of times vanished. + [Fact] + public void DuplicateProperty_ChangesHash() + { + var once = BuildHash((1060658, null)); + var twice = BuildHash((1060658, null), (1060658, null)); + + Assert.NotEqual(once, twice); + } + + // XOR is commutative, so emission order was invisible to the hash but visible in the tooltip. + [Fact] + public void PropertyOrder_ChangesHash() + { + var forward = BuildHash((1060658, "Alpha"), (1060659, "Beta")); + var reversed = BuildHash((1060659, "Beta"), (1060658, "Alpha")); + + Assert.NotEqual(forward, reversed); + } + + [Fact] + public void SwappedArguments_ChangeHash() + { + var forward = BuildHash((1063752, "10"), (1063737, "5")); + var swapped = BuildHash((1063752, "5"), (1063737, "10")); + + Assert.NotEqual(forward, swapped); + } + + [Fact] + public void IdenticalContent_ProducesIdenticalHash() + { + var first = BuildHash((1063752, "10"), (1063737, "5"), (1063740, null)); + var second = BuildHash((1063752, "10"), (1063737, "5"), (1063740, null)); + + Assert.Equal(first, second); + } + + // The client masks 0x40000000 off the 0xDC revision to match the 0xD6 hash, so the hash has + // to stay below that bit. + [Fact] + public void Hash_StaysWithinTheRevisionMask() + { + var opl = new ObjectPropertyList(null); + opl.Add(1063752, "A rather long argument that pushes the buffer past its initial size".AsSpan()); + opl.Add(1063737, "12345"); + opl.Add(1063740); + opl.Terminate(); + + Assert.Equal(0x40000000, opl.Hash & ~0x3FFFFFF); + } + + // 6- and 8-byte blocks take xxHash3's short-input paths. They still avalanche across all 26 + // kept bits, so a counter ticking down never repeats the revision it just had. + [Fact] + public void ShortNumericArguments_ConsecutiveValuesDiffer() + { + var previous = BuildHash((1060584, "0")); + + for (var charges = 1; charges < 20000; charges++) + { + var current = BuildHash((1060584, charges.ToString())); + Assert.NotEqual(previous, current); + previous = current; + } + } + + [Fact] + public void SmallPropertyBlocks_StayWellDistributed() + { + const int count = 20000; + + var withArgument = new HashSet(); + var withoutArgument = new HashSet(); + + for (var i = 0; i < count; i++) + { + withArgument.Add(BuildHash((1060584, i.ToString()))); + withoutArgument.Add(BuildHash((1060000 + i, null))); + } + + // Birthday expects ~3 collisions over a 26-bit space; allow an order of magnitude so the + // bound holds for any seed. A hash that stopped mixing collapses far past it. + Assert.True(withArgument.Count >= count - 30, $"8-byte blocks: {withArgument.Count}/{count}"); + Assert.True(withoutArgument.Count >= count - 30, $"6-byte blocks: {withoutArgument.Count}/{count}"); + } + + [Fact] + public void EmptyList_IsNonZeroAndDistinctFromPopulated() + { + var empty = new ObjectPropertyList(null); + empty.Terminate(); + + Assert.NotEqual(0, empty.Hash); + Assert.Equal(0x40000000, empty.Hash & ~0x3FFFFFF); + Assert.NotEqual(empty.Hash, BuildHash((1060658, null))); + } +} diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 58b247990..d1955ad2d 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -46,6 +46,13 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable // under the empirically confirmed ~510-char ceiling. For multi-line content use AddChunked(). public const int MaxArgumentLength = 504; + // 0xD6 header: packet id, length, unknown, serial, unknown, hash. Properties start after it. + private const int HeaderLength = 15; + + // Terminate writes the bare hash into 0xD6, SendOPLInfo writes Hash with bit 30 set, and the + // client recovers one from the other by masking off 0x40000000. The hash must stay below it. + private const int HashMask = 0x3FFFFFF; + private int _hash; private int _stringNumbersIndex; private byte[] _buffer; @@ -89,7 +96,7 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void Reset() { - _bufferPos = 15; + _bufferPos = HeaderLength; _hash = 0; _stringNumbersIndex = 0; Header = 0; @@ -120,6 +127,11 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable Resize(length); } + // xxHash3 over the finished property block. Order and repetition sensitive, unlike the + // XOR fold it replaces, which collided whenever properties were reordered or shared an + // argument. + _hash = (int)(HashUtility.ComputeHash64(_buffer.AsSpan(HeaderLength, _bufferPos - HeaderLength)) & HashMask); + var writer = new SpanWriter(_buffer); writer.Seek(_bufferPos, SeekOrigin.Begin); writer.Write(0); @@ -129,12 +141,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable writer.WritePacketLength(); } - private void AddHash(int val) - { - _hash ^= val & 0x3FFFFFF; - _hash ^= (val >> 26) & 0x3F; - } - public void Add(int number) { if (number == 0) @@ -148,8 +154,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable HeaderArgs = ""; } - AddHash(number); - var length = _bufferPos + 6; while (length > _buffer.Length) { @@ -245,9 +249,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable HeaderArgs = chars.ToString(); } - AddHash(number); - AddHash(string.GetHashCode(chars, StringComparison.Ordinal)); - var strLength = chars.Length * 2; var length = _bufferPos + 6 + strLength; while (length > _buffer.Length) @@ -295,9 +296,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable HeaderArgs = chars.ToString(); } - AddHash(number); - AddHash(string.GetHashCode(chars, StringComparison.Ordinal)); - var strLength = chars.Length * 2; var length = _bufferPos + 6 + strLength; while (length > _buffer.Length) diff --git a/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs b/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs new file mode 100644 index 000000000..b8a4f9d4b --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs @@ -0,0 +1,36 @@ +using Server.Engines.Plants; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class PlantItemPropertyListTests +{ + // The getter used to re-initialize without a Reset, appending another copy of every property + // per read. SendOPLPacketTo and SendPropertiesTo both go through it, so a pre-7.0.12 client + // looking at a plant grew the buffer without bound. + [Fact] + public void OldClientPropertyList_BuildsOnceAndIsStableAcrossReads() + { + var plant = new PlantItem(); + + try + { + var first = plant.OldClientPropertyList; + var length = first.Buffer.Length; + var hash = first.Hash; + + var second = plant.OldClientPropertyList; + var third = plant.OldClientPropertyList; + + Assert.Same(first, second); + Assert.Same(first, third); + Assert.Equal(length, third.Buffer.Length); + Assert.Equal(hash, third.Hash); + } + finally + { + plant.Delete(); + } + } +} diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index f829b0e20..5633db48c 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -73,7 +73,15 @@ public partial class PlantItem : Item, ISecurable { get { - InitializePropertyList(_oldClientPropertyList ??= new ObjectPropertyList(this)); + // Build once, like Item.PropertyList. Initializing on every read appended another copy + // of every property to the same list. InvalidateProperties rebuilds it, Reset first. + if (_oldClientPropertyList == null) + { + var list = new ObjectPropertyList(this); + _oldClientPropertyList = list; + InitializePropertyList(list); + } + return _oldClientPropertyList; } } From 114dbba6e25f0e97e8537e54025d7bfa87c03a39 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:51:10 -0700 Subject: [PATCH 07/21] fix: a stop order silently cancelled a standing follow order (#2616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug Pet is set to follow. It gets attacked and defends itself (the order flips to `Attack`), or the player tells it to `come`. The player then says `stop`. The pet quietly stops following and starts idle-wandering — the player has to re-issue the order to get it back. `ResolveStop` clears `ControlTarget`, since the transient order that is ending owns it, and then resumes the standing order. Nothing restores a target, so the resumed `Follow` has none. `DoOrderFollow` checks for a target before anything else, finds none on the very next think, says "I have no one to follow" and cancels the order to `None`: ``` follow standing order=Follow target=set persist=Follow cur=0.2 => move=0.3 attacked (defends) order=Attack target=set persist=Follow cur=0.2 => move=0.3 stop issued order=Follow target=null persist=Follow cur=0.2 => move=0.3 next think order=None target=null persist=Follow cur=0.4 => move=0.9 ``` Two things go wrong at once: - `PersistentOrder` still reads `Follow` while `ControlOrder` is `None`, so the pet never recovers on its own. - The pace falls from the active clock to the passive one. A shard that tunes `SetMoveSpeed(active, passive)` sees a following pet drop from its active move speed to its passive one for no visible reason — which is how this surfaced. Era-independent. Guard is unaffected: `DoOrderGuard` works off `ControlMaster`, not `ControlTarget`. ## The fix A standing `Follow` does not always mean "follow the master" — a pet friend can point it elsewhere with `all follow me` / `*follow me` (0x163, 0x16C) or `*follow` plus a target pick. So `SetPersistentOrder` remembers the target the standing order was given, and `ResumePersistentOrder` restores it, falling back to the master only when that target is gone. The field is runtime-only, like `PersistentOrder` itself. Doing this in `ResumePersistentOrder` rather than `ResolveStop` also covers the Friend/Unfriend/Transfer resumes, where `ControlTarget` points at a third party and the pet would otherwise have resumed its follow on *them*. It matches what `HandleInvalidControlTarget` already does before its own resume. ## Tests `Stop_WhileAttacking_FallsBackToPersistentFollow` already existed and passed, because it asserted the resumed order without checking for a target or driving a think tick. Three tests added alongside it: - `Stop_WhileAttacking_ResumedFollowTargetsTheMaster` - `Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget` — a friend's follow order is not hijacked back to the owner - `Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink` — the one that catches the cancellation Each was confirmed to fail without the change (`Expected: Follow, Actual: None`; wrong target). Full suites green: 782 UOContent, 869 Server. --- .../Tests/Mobiles/AI/PetOrderTests.cs | 53 +++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 14 +++++ 2 files changed, 67 insertions(+) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index e5d7760ed..fbc6a2f81 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -67,6 +67,59 @@ public class PetOrderTests : IDisposable Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); } + [Fact] + public void Stop_WhileAttacking_ResumedFollowTargetsTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // persistent = Follow + pet.ControlOrder = OrderType.Attack; // transient + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Equal(master, pet.ControlTarget); + } + + [Fact] + public void Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = new PlayerMobile(World.NewMobile); + friend.DefaultMobileInit(); + friend.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map); + _created.Add(friend); + + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlTarget = friend; // a pet friend said "all follow me" + pet.ControlOrder = OrderType.Follow; + pet.ControlTarget = victim; // then "all kill" — the attack order takes the target + pet.ControlOrder = OrderType.Attack; + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Equal(friend, pet.ControlTarget); // still the friend, not the owner + } + + [Fact] + public void Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + pet.ControlOrder = OrderType.Attack; + pet.ControlOrder = OrderType.Stop; // resumes Follow + + pet.AIObject.Obey(); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); // not dropped to idle + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + } + [Fact] public void Stop_WhileFollowing_CancelsToIdleNone() { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e09424328..3282493b8 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -28,16 +28,30 @@ public abstract partial class BaseAI // never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged. private bool _resolvingOrder; + // Who a standing Follow follows: usually the master, but a pet friend can point it + // elsewhere ("all follow me"). Runtime-only, like PersistentOrder. + private Mobile _persistentFollowTarget; + // The controlled-pet wander anchor (Home) is a pure function of the persistent command. internal void SetPersistentOrder(OrderType order) { PersistentOrder = order; + _persistentFollowTarget = order == OrderType.Follow ? Mobile.ControlTarget ?? Mobile.ControlMaster : null; Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location; } // Resume the persistent command without re-deriving the persistent order or anchor. private void ResumePersistentOrder() { + // The ending order owns ControlTarget and leaves it cleared or pointing elsewhere; + // without a target DoOrderFollow cancels itself to idle on the next think. + if (PersistentOrder == OrderType.Follow) + { + Mobile.ControlTarget = _persistentFollowTarget?.Deleted == false + ? _persistentFollowTarget + : Mobile.ControlMaster; + } + _resolvingOrder = true; Mobile.ControlOrder = PersistentOrder; _resolvingOrder = false; From 4bad0cc9e6c2ac0d2ddf492adc667903f3ba9f71 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:52:43 -0700 Subject: [PATCH 08/21] refactor(spawners): expose BaseSpawner DTO helpers to out-of-assembly subclasses (#2619) ## Summary `BaseSpawner.Dto.cs` exposes `DtoName`, `DtoWalkingRange`, `DtoSpawnPositionMode`, `DtoMaxSpawnAttempts`, `DtoHomeRange` and `BoundsFromHomeRange` as `private protected`, which limits them to subclasses in this assembly. A spawner subclass in another assembly that overrides `ToDto()` to produce its own `SpawnerDto` subtype cannot build the DTO without duplicating that logic. This widens them to `protected`. No behaviour change; nothing else in UOContent is affected. ## Tests - `dotnet build` clean. - An external spawner assembly builds and its test suite (419 tests) passes against this commit. --- .../UOContent/Engines/Spawners/BaseSpawner.Dto.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs index 5c0917086..5f7aaf1d0 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs @@ -53,7 +53,7 @@ public abstract partial class BaseSpawner } /// The square spawn bounds a homeRange radius represents (centered on the location). - private protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange) + protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange) { int z; int depth; @@ -78,7 +78,7 @@ public abstract partial class BaseSpawner ); } - private protected string DtoName + protected string DtoName { get { @@ -91,17 +91,17 @@ public abstract partial class BaseSpawner // MaxDelay/Team/SpawnLocationIsHome match their property and are referenced directly in ToDto.) // Raw field; the public WalkingRange is computed (falls back to HomeRange). - private protected int DtoWalkingRange => _walkingRange; + protected int DtoWalkingRange => _walkingRange; // Abandoned is a transient runtime state, not persisted -> map to Automatic (omitted). - private protected SpawnPositionMode DtoSpawnPositionMode => + protected SpawnPositionMode DtoSpawnPositionMode => _spawnPositionMode == SpawnPositionMode.Abandoned ? SpawnPositionMode.Automatic : _spawnPositionMode; // Runtime treats 0 as DefaultMaxSpawnAttempts -> map the default to 0 (omitted). - private protected int DtoMaxSpawnAttempts => _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts; + protected int DtoMaxSpawnAttempts => _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts; // The radius if SpawnBounds is exactly what it reconstructs (lossless square); otherwise -1. - private protected int DtoHomeRange + protected int DtoHomeRange { get { From 2d38ed7c6a6743d23049ed6bda38baec9d22ddd3 Mon Sep 17 00:00:00 2001 From: Robert Dickey Date: Wed, 9 Sep 2026 00:24:42 -0500 Subject: [PATCH 09/21] fix: send pre-SE bulk order cooldown message to the player (#2620) When a player selects Bulk Order Info while a cooldown is active on a pre-SE server, the vendor sends localized message 1049039 to itself instead of the requesting player. The player therefore receives no cooldown response. Change the recipient from `vendor` to `from`, matching the SE branch. Cooldown durations and bulk-order eligibility remain unchanged. Validation: This one-line fix compiled successfully in our customized 0.15.6.145 server build with zero warnings or errors. The current upstream main branch was not built locally. --- Projects/UOContent/Mobiles/Vendors/BaseVendor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index 135dd5ca4..178186ee0 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -1409,7 +1409,7 @@ namespace Server.Mobiles else { // An offer may be available in about ~1_hours~ hours. - vendor.SayTo(vendor, 1049039, $"{Math.Ceiling(totalSeconds / 3600):F0}"); + vendor.SayTo(from, 1049039, $"{Math.Ceiling(totalSeconds / 3600):F0}"); } vendor.SpeechHue = oldSpeechHue; From 865a8cf2185829042f20b51e06523172caa2d378 Mon Sep 17 00:00:00 2001 From: Robert Dickey Date: Thu, 10 Sep 2026 01:07:32 -0500 Subject: [PATCH 10/21] fix: sell Bushido and Ninjitsu books through Tokuno scribes (#2622) Tokuno scribes currently load only `SBScribe`, so the existing Zento scribe does not offer the Book of Bushido or Book of Ninjitsu. Add the existing `SBSamurai` and `SBNinja` inventories when `Core.SE && IsTokunoVendor`. This reuses the existing book prices and default stock quantities. It does not change NPC placement, trainer behavior, or inventories outside Tokuno. ### Sources and reasoning - The [official UO Ninjitsu guide](https://uo.com/wiki/ultima-online-wiki/skills/ninjitsu/) identifies the scribe at Rokuon Cultural Center in Zento as a seller of the Book of Ninjitsu. - [UOGuide: Bushido](https://www.uoguide.com/Bushido), documenting standard UO, explicitly lists Tokuno Islands scribe NPCs as a source for the Book of Bushido. This supports adding both book inventories to Tokuno scribes. - Documentation distinction: the [official UO Bushido guide](https://uo.com/wiki/ultima-online-wiki/skills/bushido/) describes the Zento seller as a samurai trainer, rather than a scribe. The Bushido scribe change follows the explicit UOGuide description. These references do not independently establish the exact historical introduction date or original prices. ### Validation - Built the change in a customized ModernUO 0.15.6.145 deployment: Release, linux-x64, zero warnings or errors. - In-game with Mondain's Legacy enabled: both books appeared on the existing Zento scribe, and a player successfully purchased both. - Reviewed the expansion/location guard for pre-SE and non-Tokuno behavior; those cases were not gameplay-tested. This exact patch has not been built against current upstream main. --- Projects/UOContent/Mobiles/Vendors/BaseVendor.cs | 2 ++ Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index 178186ee0..a6467746b 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -121,6 +121,8 @@ namespace Server.Mobiles public virtual bool IsTokunoVendor => Map == Map.Tokuno; + public virtual bool IsTerMurVendor => Map == Map.TerMur; + public virtual VendorShoeType ShoeType => VendorShoeType.Shoes; public DateTime LastRestock { get; set; } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs index 0c072141c..27430a302 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs @@ -29,6 +29,12 @@ namespace Server.Mobiles public override void InitSBInfo() { m_SBInfos.Add(new SBScribe()); + + if (Core.SE && IsTokunoVendor || Core.SA && IsTerMurVendor) + { + m_SBInfos.Add(new SBSamurai()); + m_SBInfos.Add(new SBNinja()); + } } public override void InitOutfit() From 535a0989968258b92404fbd80a7581258af3093b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:01:59 -0700 Subject: [PATCH 11/21] refactor: compile where/sort/distinct and Advanced Search through expression trees (#2625) ## Summary `where`, `sort by`, `distinct` and the Advanced Search property test now compile through expression trees instead of the hand-rolled IL in `Emitter.cs`. The emitter and its three `Reflection.Emit` compilers were RunUO-era code from before expression trees existed; they were the only way to avoid per-object reflection at the time, and they are not any more. Net: ~1,900 lines of IL bookkeeping deleted, one comparison engine instead of two, faster per object, cheaper to compile, collectible, and `Nullable` properties work. ## Why - **Bugs hid in the IL.** Equality on a type with value semantics but no `IComparable` (`TextDefinition`) was a raw `ceq`, so `where Message = 1060847` never matched. A chained binding (`Message.Number`) dereferenced every link unguarded, so the first swept object with a null intermediate killed the sweep with an NRE. A constant narrower or unsigned than `int` threw before compiling. A struct with no `CompareTo` reached `ceq` on two unboxed values, which is invalid IL. Every dynamic assembly was `Run`, so each `[global where` grew the process for good. - **Advanced Search had its own engine.** Per entity and per leaf it re-split the expression, scanned the runtime type's properties by name, read the value by reflection, re-parsed the right-hand side and dispatched on type through ~300 lines of `CompareValues`. Same job, second implementation, second set of bugs. ## What changed - `ICondition.Compile(MethodEmitter)` becomes `ICondition.Build(ParameterExpression)` returning an `Expression`. `ConditionalCompiler` assembles a `Func`, `SortCompiler` a `Comparison`, `DistinctCompiler` both comparer interfaces over one lambda. The parsed constant is an `Expression.Constant`, so the generated type, its constructor and the per-condition field for non-primitive constants disappear with `PropertyValue`. - `PropertyExpressions` holds the shared pieces: the chain walk with its null-intermediate guard, `CompareTo` resolution with the old null ordering, the integral/enum operator path, and constant parsing. - `BaseExtension.Optimize` loses its `ref AssemblyEmitter` parameter. An out-of-tree extension that overrides `Optimize` needs to drop it. - Advanced Search keeps its grammar (`~` negates, `@` is AND, `|` is OR and binds looser, string `>` is "starts with") and translates each leaf into the same conditions, compiled once per declaring type per search and memoized across the workers. Float and double keep their typed-precision tolerance through a small `EpsilonCondition` in the Advanced Search folder. ## Semantics preserved - Equality on a non-comparable reference type is `object.Equals`, never reference identity. A non-comparable struct boxes into the same call. - A null intermediate in a chained binding is no match, and stays no match under negation. Sort and distinct read it as `default(T)`. - Unsigned relational compares stay unsigned; integral primitives and enums use the operator directly, nothing widens to a signed type. `float`, `double`, `decimal`, `string` and structs still go through the type's own `CompareTo`, so `string` equality stays culture-sensitive exactly as before. - Only `==` and `!=` are valid for non-comparable types; a relational operator still throws at build time. - `TypeCondition` is still first and still null-checks the cast target. ## Behavior changes - **`Nullable` works**, with C# lifted semantics in `where`: two nulls are equal, a null and a value are unequal, a null satisfies no relation. Sort keeps a total order with unset values at one end. A null *reference* keeps the ordering it had. - **Advanced Search**: a leaf that cannot be parsed or resolved is no match even under `~` (it used to negate the failure and match every entity); `null` is the null value for equality on strings, nullables and reference types, as in `where`; dotted names walk into a property; static properties are no longer searchable. ## Measurements `where`, from the handoff (Debug test host, single condition, ratios not absolutes): | Approach | ns/object | Compile | Collectible | LOC | |---|---:|---:|---|---:| | `AssemblyBuilder` + IL (before) | 20.5 | 0.311 ms | no (`Run`) | ~1,916 | | Expression trees (after) | ~12 | 0.187 ms warm | yes | ~600 | Advanced Search, Release, one `SkillTeleporter`, 2M evaluations per leaf: | Leaf | Before | After | |---|---:|---:| | `Hue=5` | 137 ns | 20 ns | | `Name~~gate` | 121 ns | 39 ns | | `Skill=Magery` | 116 ns | 21 ns | | `Weight>0.5` | 122 ns | 28 ns | Plus 0.5 to 2 ms to compile each declaring type a search meets (12 ms for the first compile in the process). All of it runs on the search workers; nothing new touches the loop. ## Also fixed: Advanced Search map filters Found while testing the property test in game. The map boxes are independent checkboxes, but the worker applied each ticked map as "must be on this map", so ticking two or more (all maps and Internal, say) rejected every entity before any other filter ran. Present since #1649; the default of Felucca alone never showed it. An entity now passes when its map is any of the ticked ones, with none ticked meaning no map constraint. Pinned by a worker test. ## Test plan - [x] `UOContent.Tests`: 862 passed, 0 failed - [x] `Server.Tests`: 869 passed, 0 failed - [x] Every commit builds and its tests pass on its own (bisectable) - [ ] In game: `[global where`, `[area where`, `[condition`, `sort by`, `distinct`, Advanced Search property test --- .../Tests/Commands/ChainedBindingSortTests.cs | 113 +++ .../Commands/ConditionalCompilerEdgeTests.cs | 112 +++ .../Tests/Commands/DistinctComparerTests.cs | 97 +++ .../Tests/Commands/EmitterRobustnessTests.cs | 103 +++ .../NullablePropertyConditionTests.cs | 143 ++++ .../Tests/Commands/ObjectConditionalTests.cs | 200 +++++ .../AdvancedSearchConditionsTests.cs | 232 ++++++ .../AdvancedSearchTypesTests.cs | 24 +- .../AdvancedSearchUtilitiesTests.cs | 119 --- .../AdvancedSearchWorkerTests.cs | 105 +++ .../Generic/Extensions/BaseExtension.cs | 6 +- .../Compilers/ConditionalCompiler.cs | 614 ++++----------- .../Extensions/Compilers/DistinctCompiler.cs | 299 ++----- .../Compilers/PropertyExpressions.cs | 407 ++++++++++ .../Extensions/Compilers/SortCompiler.cs | 178 ++--- .../Generic/Extensions/DistinctExtension.cs | 6 +- .../Generic/Extensions/SortExtension.cs | 6 +- .../Generic/Extensions/WhereExtension.cs | 4 +- .../Generic/Implementors/ObjectConditional.cs | 10 +- .../AdvancedSearchConditions.cs | 432 +++++++++++ .../Advanced Search/AdvancedSearchGump.cs | 3 +- .../AdvancedSearchThreadWorker.cs | 117 ++- .../AdvancedSearchUtilities.cs | 379 --------- Projects/UOContent/Misc/Emitter.cs | 727 ------------------ 24 files changed, 2294 insertions(+), 2142 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs create mode 100644 Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs create mode 100644 Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs delete mode 100644 Projects/UOContent/Misc/Emitter.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs new file mode 100644 index 000000000..6283dd99a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// `sort by` and `distinct` compile the same property chain the conditionals do, and had the same +// crash on a null link in the middle. Ordering cannot answer "no match" the way a condition can, +// so an unreadable binding reads as the default value instead -- which is what a null link means. +[Collection("Sequential UOContent Tests")] +public class ChainedBindingSortTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + _items.Add(tp); + return tp; + } + + private static Property Bind(string binding) + { + var prop = new Property(binding); + prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + return prop; + } + + private static IComparer Sorter(string binding) => + SortCompiler.Compile( + typeof(SkillTeleporter), + [new OrderInfo(Bind(binding), true)] + ); + + private static IComparer Distincter(string binding) => + DistinctCompiler.Compile( + typeof(SkillTeleporter), + [Bind(binding)] + ); + + [Fact] + public void SortingOnAChainSurvivesANullIntermediate() + { + var named = Teleporter(TextDefinition.Of("Alpha")); + var blank = Teleporter(null); + + var comparer = Sorter("Message.String"); + + // A consistent total order is the contract; which end the blanks land on is not asserted. + var forward = comparer.Compare(named, blank); + + Assert.NotEqual(0, forward); + Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(blank, named))); + Assert.Equal(0, comparer.Compare(blank, blank)); + Assert.Equal(0, comparer.Compare(named, named)); + } + + [Fact] + public void SortingOnAChainStillOrdersReadableValues() + { + var alpha = Teleporter(TextDefinition.Of("Alpha")); + var beta = Teleporter(TextDefinition.Of("Beta")); + + var comparer = Sorter("Message.String"); + + Assert.True(comparer.Compare(alpha, beta) < 0); + Assert.True(comparer.Compare(beta, alpha) > 0); + } + + [Fact] + public void DistinctOnAChainSurvivesANullIntermediate() + { + var named = Teleporter(TextDefinition.Of("Alpha")); + var blank = Teleporter(null); + var alsoBlank = Teleporter(null); + + var comparer = Distincter("Message.String"); + + Assert.NotEqual(0, comparer.Compare(named, blank)); + Assert.Equal(0, comparer.Compare(blank, alsoBlank)); + } + + // An unchained binding never had the problem and must keep working untouched. + [Fact] + public void SortingOnAPlainBindingIsUnchanged() + { + var low = Teleporter(null); + low.Hue = 1; + + var high = Teleporter(null); + high.Hue = 2; + + var comparer = Sorter("Hue"); + + Assert.True(comparer.Compare(low, high) < 0); + Assert.True(comparer.Compare(high, low) > 0); + Assert.Equal(0, comparer.Compare(low, low)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs new file mode 100644 index 000000000..f430973a9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value +// type (Message.Number) reused a temp while its value was still live, so every pair compared +// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is +// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port. +[Collection("Sequential UOContent Tests")] +public class ConditionalCompilerEdgeTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + _items.Add(tp); + return tp; + } + + private static Property Bind(Type type, string binding) + { + var prop = new Property(binding); + prop.BindTo(type, PropertyAccess.Read); + return prop; + } + + [Fact] + public void SortingOnAChainedValueTypeOrdersValues() + { + var low = Teleporter(TextDefinition.Of(1060847)); + var high = Teleporter(TextDefinition.Of(1060848)); + + var comparer = SortCompiler.Compile( + typeof(SkillTeleporter), + [new OrderInfo(Bind(typeof(SkillTeleporter), "Message.Number"), true)] + ); + + Assert.True(comparer.Compare(low, high) < 0); + Assert.True(comparer.Compare(high, low) > 0); + Assert.Equal(0, comparer.Compare(low, low)); + } + + [Fact] + public void DistinctOnAChainedValueTypeSeparatesValues() + { + var low = Teleporter(TextDefinition.Of(1060847)); + var high = Teleporter(TextDefinition.Of(1060848)); + var alsoLow = Teleporter(TextDefinition.Of(1060847)); + + var comparer = DistinctCompiler.Compile( + typeof(SkillTeleporter), + [Bind(typeof(SkillTeleporter), "Message.Number")] + ); + + Assert.NotEqual(0, comparer.Compare(low, high)); + Assert.Equal(0, comparer.Compare(low, alsoLow)); + } + + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public Rectangle2D Bounds { get; set; } = new(new Point2D(1, 2), new Point2D(3, 4)); + } + + private static bool Check(ComparisonOperator op, string value) + { + var compiled = ConditionalCompiler.Compile( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(Bind(typeof(Subject), "Bounds"), false, op, value)] + ); + + return compiled.Verify(new Subject()); + } + + [Fact] + public void NonComparableStructComparesByValueEquality() + { + Assert.True(Check(ComparisonOperator.Equal, "(1, 2)+(3, 4)")); + Assert.False(Check(ComparisonOperator.Equal, "(1, 2)+(3, 5)")); + Assert.False(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 4)")); + Assert.True(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 5)")); + } + + // Only == and != are meaningful without a CompareTo; a relational operator is an error at + // compile time, not garbage at run time. + [Theory] + [InlineData(ComparisonOperator.Greater)] + [InlineData(ComparisonOperator.GreaterEqual)] + [InlineData(ComparisonOperator.Lesser)] + [InlineData(ComparisonOperator.LesserEqual)] + public void NonComparableStructRejectsRelationalOperators(ComparisonOperator op) + { + Assert.Throws(() => Check(op, "(1, 2)+(3, 4)")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs new file mode 100644 index 000000000..e1068f6e2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// The distinct comparer doubles as an IEqualityComparer: objects that compare equal on every +// listed property must hash the same, whatever shape the property is -- an int, a reference that +// may be null, a struct, or a chain with a null link in the middle. +[Collection("Sequential UOContent Tests")] +public class DistinctComparerTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(Action setup = null) + { + var tp = new SkillTeleporter(); + setup?.Invoke(tp); + _items.Add(tp); + return tp; + } + + private static IEqualityComparer Comparer(params string[] bindings) + { + var props = new Property[bindings.Length]; + + for (var i = 0; i < bindings.Length; i++) + { + props[i] = new Property(bindings[i]); + props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + } + + return (IEqualityComparer)DistinctCompiler.Compile(typeof(SkillTeleporter), props); + } + + [Fact] + public void EqualObjectsHashTheSameAcrossPropertyShapes() + { + var comparer = Comparer("Hue", "Name", "Location", "Message.String"); + + var one = Teleporter(tp => + { + tp.Hue = 7; + tp.Name = "Gate"; + tp.Location = new Point3D(1, 2, 3); + tp.Message = TextDefinition.Of("Alpha"); + }); + + var two = Teleporter(tp => + { + tp.Hue = 7; + tp.Name = "Gate"; + tp.Location = new Point3D(1, 2, 3); + tp.Message = TextDefinition.Of("Alpha"); + }); + + Assert.True(comparer.Equals(one, two)); + Assert.Equal(comparer.GetHashCode(one), comparer.GetHashCode(two)); + } + + [Fact] + public void NullReferencesAndNullChainLinksHashWithoutThrowing() + { + var comparer = Comparer("Name", "Message.String"); + + var blank = Teleporter(); + var alsoBlank = Teleporter(); + + Assert.True(comparer.Equals(blank, alsoBlank)); + Assert.Equal(comparer.GetHashCode(blank), comparer.GetHashCode(alsoBlank)); + } + + [Fact] + public void DifferingObjectsAreNotEqual() + { + var comparer = Comparer("Hue", "Location"); + + var one = Teleporter(tp => tp.Hue = 1); + var two = Teleporter(tp => tp.Hue = 2); + + Assert.False(comparer.Equals(one, two)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs new file mode 100644 index 000000000..997d8b79f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -0,0 +1,103 @@ +using System; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Xunit; + +namespace UOContent.Tests.Commands; + +// The conditional compiler emits into a fresh dynamic assembly for every command invocation -- +// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one +// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant +// types, so a comparison against anything narrower or unsigned than int threw instead of running. +public class EmitterRobustnessTests +{ + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public byte Tiny { get; set; } = 5; + + [CommandProperty(AccessLevel.GameMaster)] + public sbyte Signed { get; set; } = -5; + + [CommandProperty(AccessLevel.GameMaster)] + public short Small { get; set; } = -300; + + [CommandProperty(AccessLevel.GameMaster)] + public ushort Key { get; set; } = 40000; + + [CommandProperty(AccessLevel.GameMaster)] + public uint Big { get; set; } = 3_000_000_000; + + [CommandProperty(AccessLevel.GameMaster)] + public ulong Huge { get; set; } = 18_000_000_000_000_000_000; + } + + private static bool Check(string binding, ComparisonOperator op, string value) + { + var prop = new Property(binding); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var compiled = ConditionalCompiler.Compile( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(prop, false, op, value)] + ); + + return compiled.Verify(new Subject()); + } + + [Theory] + [InlineData("Tiny", "5")] + [InlineData("Signed", "-5")] + [InlineData("Small", "-300")] + [InlineData("Key", "40000")] + [InlineData("Big", "3000000000")] + [InlineData("Huge", "18000000000000000000")] + public void NarrowAndUnsignedIntegersCompareByEquality(string binding, string value) + { + Assert.True(Check(binding, ComparisonOperator.Equal, value)); + Assert.False(Check(binding, ComparisonOperator.NotEqual, value)); + } + + [Theory] + [InlineData("Tiny", "4")] + [InlineData("Signed", "-6")] + [InlineData("Small", "-301")] + [InlineData("Key", "39999")] + [InlineData("Big", "2999999999")] + [InlineData("Huge", "17999999999999999999")] + public void NarrowAndUnsignedIntegersCompareRelationally(string binding, string value) + { + Assert.True(Check(binding, ComparisonOperator.Greater, value)); + Assert.False(Check(binding, ComparisonOperator.Lesser, value)); + } + + // Unsigned values above the signed range must not wrap into a negative comparison. + [Fact] + public void UnsignedComparisonsDoNotWrapThroughSignedMath() + { + Assert.True(Check("Big", ComparisonOperator.Greater, "2147483647")); + Assert.True(Check("Huge", ComparisonOperator.Greater, "9223372036854775807")); + } + + [Fact] + public void EmittedAssembliesAreCollectible() + { + var prop = new Property("Tiny"); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var compiled = ConditionalCompiler.Build( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(prop, false, ComparisonOperator.Equal, "5")] + ).Compile(); + + var method = compiled.Method; + + Assert.True( + method.IsCollectible, + "The conditional compiler compiles one delegate per command invocation; code that is " + + $"not collectible ({method.GetType().Name} in {method.Module}) can never be unloaded, " + + "so every [global where would grow the process for good." + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs b/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs new file mode 100644 index 000000000..b1f34b2f3 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs @@ -0,0 +1,143 @@ +using System; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Xunit; + +namespace UOContent.Tests.Commands; + +// A `where` clause against a Nullable property. No [CommandProperty] in the tree is nullable +// yet, so nothing is broken in practice -- but the conditional compiler should not fall over on +// one either: a set value compares as its underlying type, an unset one equals `null` and +// satisfies no relation, just as C#'s lifted operators would have it. +public class NullablePropertyConditionTests +{ + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public int? Count { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public TimeSpan? Delay { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public SkillName? Skill { get; set; } + } + + private static bool Check(Subject subject, string binding, ComparisonOperator op, string value) + { + var prop = new Property(binding); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var conditional = new ObjectConditional( + typeof(Subject), + [[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]] + ); + + return conditional.CheckCondition(subject); + } + + [Fact] + public void SetValueComparesByEquality() + { + var subject = new Subject { Count = 5 }; + + Assert.True(Check(subject, "Count", ComparisonOperator.Equal, "5")); + Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "4")); + Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "4")); + Assert.False(Check(subject, "Count", ComparisonOperator.NotEqual, "5")); + } + + [Fact] + public void SetValueComparesRelationally() + { + var subject = new Subject { Count = 5 }; + + Assert.True(Check(subject, "Count", ComparisonOperator.Greater, "4")); + Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "4")); + Assert.True(Check(subject, "Count", ComparisonOperator.GreaterEqual, "5")); + Assert.True(Check(subject, "Count", ComparisonOperator.LesserEqual, "5")); + } + + [Fact] + public void UnsetValueEqualsNull() + { + Assert.True(Check(new Subject(), "Count", ComparisonOperator.Equal, "null")); + Assert.False(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.Equal, "null")); + Assert.True(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.NotEqual, "null")); + } + + // Lifted semantics: null is never greater, lesser, or equal to a value -- only unequal. + [Fact] + public void UnsetValueSatisfiesNoRelation() + { + var subject = new Subject(); + + Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "0")); + Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.Greater, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.GreaterEqual, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.LesserEqual, "0")); + } + + // A struct that compares through CompareTo rather than a primitive operator. + [Fact] + public void NullableStructComparesThroughCompareTo() + { + var set = new Subject { Delay = TimeSpan.FromSeconds(5) }; + + Assert.True(Check(set, "Delay", ComparisonOperator.Equal, "00:00:05")); + Assert.True(Check(set, "Delay", ComparisonOperator.Greater, "00:00:04")); + Assert.False(Check(set, "Delay", ComparisonOperator.Lesser, "00:00:04")); + Assert.False(Check(set, "Delay", ComparisonOperator.Equal, "null")); + + var unset = new Subject(); + + Assert.True(Check(unset, "Delay", ComparisonOperator.Equal, "null")); + Assert.False(Check(unset, "Delay", ComparisonOperator.Greater, "00:00:04")); + Assert.False(Check(unset, "Delay", ComparisonOperator.Lesser, "00:00:04")); + } + + [Fact] + public void NullableEnumComparesByNameAndOrder() + { + var set = new Subject { Skill = SkillName.Magery }; + + Assert.True(Check(set, "Skill", ComparisonOperator.Equal, "Magery")); + Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "Anatomy")); + Assert.True(Check(set, "Skill", ComparisonOperator.Greater, "Alchemy")); + Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "null")); + + var unset = new Subject(); + + Assert.True(Check(unset, "Skill", ComparisonOperator.Equal, "null")); + Assert.False(Check(unset, "Skill", ComparisonOperator.Equal, "Magery")); + Assert.False(Check(unset, "Skill", ComparisonOperator.Greater, "Alchemy")); + } + + // `sort by` on a nullable: values order by the underlying type and an unset value takes a + // consistent place at one end, the same convention a null reference already had. + [Fact] + public void SortingOnANullableOrdersValuesAndPlacesUnsetConsistently() + { + var prop = new Property("Count"); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var comparer = SortCompiler.Compile(typeof(Subject), [new OrderInfo(prop, true)]); + + var low = new Subject { Count = 1 }; + var high = new Subject { Count = 2 }; + var unset = new Subject(); + + Assert.True(comparer.Compare(low, high) < 0); + Assert.True(comparer.Compare(high, low) > 0); + Assert.Equal(0, comparer.Compare(low, low)); + + var forward = comparer.Compare(high, unset); + + Assert.NotEqual(0, forward); + Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(unset, high))); + Assert.Equal(0, comparer.Compare(unset, new Subject())); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs new file mode 100644 index 000000000..3d43eca42 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// `where` compiles conditions to IL. Two shapes were broken there: a property type with value +// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and +// a chained binding dereferenced every link with no null guard (so the first object with a null +// intermediate took the whole sweep down with an NRE). +[Collection("Sequential UOContent Tests")] +public class ObjectConditionalTests : IDisposable +{ + private readonly List _items = []; + private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer }; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + _from.Delete(); + } + + private SkillTeleporter Teleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + _items.Add(tp); + return tp; + } + + private bool Check(object target, params string[] condition) + { + var args = new string[condition.Length + 1]; + args[0] = nameof(SkillTeleporter); + Array.Copy(condition, 0, args, 1, condition.Length); + + return ObjectConditional.ParseDirect(_from, args, 0, args.Length).CheckCondition(target); + } + + [Fact] + public void EqualityUsesValueSemanticsNotReferenceIdentity() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.True(Check(tp, "Message", "=", "1060847")); + } + + [Fact] + public void InequalityUsesValueSemanticsNotReferenceIdentity() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.False(Check(tp, "Message", "!=", "1060847")); + Assert.True(Check(tp, "Message", "!=", "1234567")); + } + + [Fact] + public void StringValuedDefinitionsCompareByValue() + { + var tp = Teleporter(TextDefinition.Of("Hail, traveller.")); + + Assert.True(Check(tp, "Message", "=", "Hail, traveller.")); + Assert.False(Check(tp, "Message", "=", "Farewell.")); + } + + [Fact] + public void NullComparisonStillWorks() + { + Assert.True(Check(Teleporter(null), "Message", "=", "null")); + Assert.False(Check(Teleporter(TextDefinition.Of(1060847)), "Message", "=", "null")); + } + + // The sweep case: [global where SkillTeleporter Message.Number = X hits teleporters whose + // Message was never set long before it hits one that matches. + [Fact] + public void ChainedBindingOnANullIntermediateIsFalseNotAnException() + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "Message.Number", "=", "1060847")); + } + + [Fact] + public void ChainedBindingStillMatchesWhenIntermediateIsPresent() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.True(Check(tp, "Message.Number", "=", "1060847")); + Assert.False(Check(tp, "Message.Number", "=", "1234567")); + } + + [Fact] + public void ChainedBindingOnANullIntermediateIsFalseUnderNegationToo() + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "not", "Message.Number", "=", "1060847")); + } + + // The string operators (contains / starts / ends / =~) compile through StringCondition, which + // chains the same way ComparisonCondition does and so had the same null-intermediate crash. + [Theory] + [InlineData("contains")] + [InlineData("contains~")] + [InlineData("starts")] + [InlineData("ends")] + [InlineData("=~")] + [InlineData("!=~")] + public void StringOperatorsOnANullIntermediateAreFalseNotAnException(string oper) + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "Message.String", oper, "gate")); + } + + [Fact] + public void StringOperatorsOnANullIntermediateAreFalseUnderNegationToo() + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "not", "Message.String", "contains", "gate")); + } + + [Fact] + public void StringOperatorsStillMatchThroughAChain() + { + var tp = Teleporter(TextDefinition.Of("Moongate")); + + Assert.True(Check(tp, "Message.String", "contains", "gate")); + Assert.True(Check(tp, "Message.String", "starts", "Moon")); + Assert.True(Check(tp, "Message.String", "ends", "gate")); + Assert.True(Check(tp, "Message.String", "=~", "moongate")); + Assert.False(Check(tp, "Message.String", "contains", "portal")); + } + + // The chain resolves here -- Message is set -- but its String is null because the definition + // holds a cliloc. That is the final value, which StringCondition already guarded. + [Fact] + public void StringOperatorsHandleANullFinalValue() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.False(Check(tp, "Message.String", "contains", "gate")); + Assert.False(Check(tp, "Message.String", "=~", "gate")); + } + + [Fact] + public void UnchainedStringOperatorsStillWork() + { + var tp = Teleporter(null); + tp.Name = "Moongate"; + + Assert.True(Check(tp, "Name", "contains", "gate")); + Assert.True(Check(tp, "Name", "=~", "moongate")); + Assert.False(Check(tp, "Name", "contains", "portal")); + } + + // Guards for the comparison paths the equality change must not disturb. Ints, strings and + // enums are IComparable, so they route through CompareTo and never reach CompareEquality -- + // these prove that routing is intact. + [Fact] + public void NumericComparisonsStillWork() + { + var tp = Teleporter(null); + tp.Hue = 42; + + Assert.True(Check(tp, "Hue", "=", "42")); + Assert.False(Check(tp, "Hue", "=", "43")); + Assert.True(Check(tp, "Hue", ">", "41")); + Assert.True(Check(tp, "Hue", "<", "43")); + Assert.True(Check(tp, "Hue", "!=", "43")); + } + + [Fact] + public void StringComparisonsStillWork() + { + var tp = Teleporter(null); + tp.Name = "gate"; + + Assert.True(Check(tp, "Name", "=", "gate")); + Assert.False(Check(tp, "Name", "=", "portal")); + } + + [Fact] + public void EnumComparisonsStillWork() + { + var tp = Teleporter(null); + tp.Skill = SkillName.Magery; + + Assert.True(Check(tp, "Skill", "=", "Magery")); + Assert.False(Check(tp, "Skill", "=", "Anatomy")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs new file mode 100644 index 000000000..df795a440 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs @@ -0,0 +1,232 @@ +using System; +using Server; +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +// The Advanced Search property test compiles through the same conditions a `where` clause does. +// The grammar is the gump's own; these pin its operators and precedence, and that a leaf which +// cannot be resolved or parsed is "no match" rather than an exception on the search worker. +public class AdvancedSearchConditionsTests +{ + public sealed class Inner + { + public int Value { get; set; } = 7; + } + + public sealed class LegacyParseType + { + public string Value { get; private init; } + public static LegacyParseType Parse(string s) => new() { Value = s }; + public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + } + + public class Subject + { + public int Hue { get; set; } = 5; + public string Name { get; set; } = "Moongate"; + public bool Movable { get; set; } = true; + public bool Visible { get; set; } + public double Weight { get; set; } = 0.1 + 0.2; + public float Ratio { get; set; } = 0.1f; + public TimeSpan Delay { get; set; } = TimeSpan.FromMinutes(5); + public Guid Id { get; set; } = Guid.Parse("00000000-0000-0000-0000-000000000001"); + public Layer Layer { get; set; } = Layer.OneHanded; + public object Reference { get; set; } = new(); + public LegacyParseType Legacy { get; set; } = LegacyParseType.Parse("alpha"); + public Inner Child { get; set; } = new(); + public int? Maybe { get; set; } + } + + public sealed class Derived : Subject + { + } + + private static bool Check(string test, Subject subject = null) => + AdvancedSearchConditions.Compile(typeof(Subject), test)(subject ?? new Subject()); + + [Theory] + [InlineData("Hue=abc")] // not a number + [InlineData("Hue=99999999999")] // overflows int + [InlineData("Hue=0xZZ")] // bad hex + [InlineData("Layer=Bogus")] // not an enum member + [InlineData("Delay=notaspan")] + [InlineData("Bogus=1")] // no such property + [InlineData("Hue=")] // no value + [InlineData("Hue")] // no operator + public void UnusableLeafIsNoMatchAndDoesNotThrow(string test) + { + var ex = Record.Exception(() => Assert.False(Check(test))); + Assert.Null(ex); + } + + // The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity. + [Fact] + public void UnusableLeafStaysNoMatchUnderNegation() + { + Assert.False(Check("~Hue=abc")); + Assert.False(Check("~Bogus=1")); + } + + [Fact] + public void NumericOperators() + { + Assert.True(Check("Hue=5")); + Assert.True(Check("Hue==5")); + Assert.True(Check("Hue=0x5")); + Assert.False(Check("Hue!=5")); + Assert.True(Check("Hue!4")); + Assert.True(Check("Hue>4")); + Assert.True(Check("Hue<6")); + Assert.True(Check("Hue>=5")); + Assert.True(Check("Hue<=5")); + Assert.False(Check("Hue~5")); + } + + [Fact] + public void NegationPrefix() + { + Assert.False(Check("~Hue=5")); + Assert.True(Check("~Hue=4")); + } + + // '|' binds looser than '@'. + [Theory] + [InlineData("Movable=1", true)] + [InlineData("Visible=1", false)] + [InlineData("Visible=1@Visible=1|Movable=1", true)] // (F&&F)||T + [InlineData("Movable=1|Visible=1@Visible=1", true)] // T||(F&&F) + [InlineData("Movable=1@Visible=1", false)] + [InlineData("Movable=1@Movable=1", true)] + [InlineData("Visible=1|Visible=1", false)] + public void Precedence(string test, bool expected) + { + Assert.Equal(expected, Check(test)); + } + + [Theory] + [InlineData("Name=Moongate", true)] + [InlineData("Name=moongate", false)] + [InlineData("Name!Moongate", false)] + [InlineData("Name>Moon", true)] + [InlineData("Namemoon", true)] + [InlineData("Name~=Moon", false)] // no such string operator + public void StringOperators(string test, bool expected) + { + Assert.Equal(expected, Check(test)); + } + + [Fact] + public void StringNullIsTheNullStringForEqualityAndTextOtherwise() + { + var unnamed = new Subject { Name = null }; + + Assert.True(Check("Name=null", unnamed)); + Assert.False(Check("Name=null")); + Assert.False(Check("Name~null", unnamed)); + Assert.True(Check("Name~null", new Subject { Name = "nullable" })); + } + + [Fact] + public void BooleanAcceptsSwitchWordsAndOnlyEquality() + { + Assert.True(Check("Movable=true")); + Assert.True(Check("Movable=1")); + Assert.True(Check("Movable=on")); + Assert.True(Check("Movable=Enabled")); + Assert.True(Check("Visible=off")); + Assert.False(Check("Movable=maybe")); + Assert.False(Check("Movable>0")); + } + + [Fact] + public void EnumIgnoresCaseAndOrdersByValue() + { + Assert.True(Check("Layer=onehanded")); + Assert.True(Check("Layer>Invalid")); + Assert.False(Check("Layer=TwoHanded")); + } + + // Floating point compares to a tolerance derived from the typed value. + [Fact] + public void FloatingPointUsesEpsilon() + { + Assert.True(Check("Weight=0.3")); + Assert.False(Check("Weight=0.31")); + Assert.True(Check("Weight>0.2")); + Assert.True(Check("Weight<=0.3")); + Assert.True(Check("Ratio=0.1")); + Assert.False(Check("Ratio=0.2")); + } + + [Fact] + public void TimeSpanParsesAndCompares() + { + Assert.True(Check("Delay=00:05:00")); + Assert.False(Check("Delay=00:10:00")); + Assert.True(Check("Delay>00:01:00")); + } + + [Fact] + public void ValueTypeWithoutHotPathParsesThroughTypes() + { + Assert.True(Check("Id=00000000-0000-0000-0000-000000000001")); + Assert.False(Check("Id=00000000-0000-0000-0000-000000000002")); + } + + // A pre-IParsable type with only a static Parse(string) is still searchable, compared against + // a real parsed instance rather than the raw text. + [Fact] + public void LegacyParseStringParsesThroughTypes() + { + Assert.True(Check("Legacy=alpha")); + Assert.False(Check("Legacy=beta")); + } + + // A reference type with no CompareTo answers equality only; ordering is no match, not a throw. + [Fact] + public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow() + { + var ex = Record.Exception(() => Assert.False(Check("Reference>whatever"))); + Assert.Null(ex); + } + + [Fact] + public void DottedNameWalksIntoTheProperty() + { + Assert.True(Check("Child.Value=7")); + Assert.False(Check("Child.Value=8")); + Assert.False(Check("Child.Value=7", new Subject { Child = null })); + Assert.False(Check("~Child.Value=7", new Subject { Child = null })); + } + + [Fact] + public void NullableCompares() + { + Assert.True(Check("Maybe=null")); + Assert.False(Check("Maybe=5")); + Assert.True(Check("Maybe=5", new Subject { Maybe = 5 })); + Assert.True(Check("Maybe>4", new Subject { Maybe = 5 })); + } + + // Two runtime types resolving the same declared property share one compiled predicate. + [Fact] + public void SubclassesShareThePredicateCompiledForTheDeclaringType() + { + var cache = new AdvancedSearchConditions.Cache(); + + var forBase = AdvancedSearchConditions.GetPredicate(cache, typeof(Subject), "Hue=5"); + var forDerived = AdvancedSearchConditions.GetPredicate(cache, typeof(Derived), "Hue=5"); + + Assert.Same(forBase, forDerived); + Assert.True(forDerived(new Derived())); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs index ea14f3cf2..36f97eaf0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -7,21 +7,27 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class AdvancedSearchTypesTests { + public class Subject + { + public Poison Venom { get; set; } + } + [Fact] - public void CompareValues_Poison_ReferenceTypeParsedViaTypes() + public void Poison_ReferenceTypeParsedViaTypes() { PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set - // Poison is a reference type implementing ISpanParsable; it can't use the compile-time span - // path and routes through the shared Server.Types converter. Poison.Parse returns the - // registered singleton, so "= Lethal" is a reference-equality match — this is the case that - // previously compared a Poison against the raw string and always failed. - var prop = Poison.Lethal; - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "=")); + // Poison is a reference type implementing ISpanParsable; its value routes through the shared + // Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a + // reference-equality match — this is the case that previously compared a Poison against the + // raw string and always failed. + var subject = new Subject { Venom = Poison.Lethal }; + + Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject)); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject)); var ex = Record.Exception(() => - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "="))); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject))); Assert.Null(ex); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs deleted file mode 100644 index 9176fae4a..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using Server; -using Server.Engines.AdvancedSearch; -using Xunit; - -namespace UOContent.Tests; - -public class AdvancedSearchUtilitiesTests -{ - [Theory] - [InlineData("abc")] // not a number -> was FormatException - [InlineData("99999999999")] // overflows int -> was OverflowException - [InlineData("0xZZ")] // bad hex -> was FormatException - public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value) - { - var ex = Record.Exception(() => - { - var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">"); - Assert.False(result); - }); - Assert.Null(ex); - } - - [Theory] - [InlineData("Bogus")] // not a member -> was ArgumentException - [InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException - public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value) - { - var ex = Record.Exception(() => - { - var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "="); - Assert.False(result); - }); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_ValidEnum_IgnoresCase() - { - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "=")); - } - - [Theory] - // leaf value is "T"/"F"; evalLeaf returns leaf=="T" - [InlineData("T", true)] - [InlineData("F", false)] - [InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F) - [InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F) - [InlineData("T@F", false)] - [InlineData("T@T", true)] - [InlineData("F|F", false)] - public void EvaluateBoolean_Precedence(string expr, bool expected) - { - // State is unused here; the leaf evaluator just checks the span equals "T". - var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T")); - Assert.Equal(expected, result); - } - - [Fact] - public void CompareValues_ReferenceType_EqualityByString_NoThrow() - { - // A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw, - // and ordering operators must return false rather than throwing. - var ex = Record.Exception(() => - { - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">")); - }); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_TimeSpan_ParsesViaSpanParsable() - { - // TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently - // returned no-match. ISpanParsable parses it correctly. - var prop = TimeSpan.FromMinutes(5); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "=")); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">")); - } - - [Fact] - public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow() - { - var ex = Record.Exception(() => - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "="))); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_Guid_ValueTypeParsedViaTypes() - { - // Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and - // compared by value. - var g = Guid.Parse("00000000-0000-0000-0000-000000000001"); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "=")); - } - - // A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface — - // the Faction/Town shape. Types must still discover its Parse by reflection. - private sealed class LegacyParseType - { - public string Value { get; private init; } - public static LegacyParseType Parse(string s) => new() { Value = s }; - public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; - public override int GetHashCode() => Value?.GetHashCode() ?? 0; - } - - [Fact] - public void CompareValues_LegacyParseString_ParsedViaTypes() - { - // Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the - // legacy Parse by reflection, so we compare against a real parsed instance, not the raw text. - var prop = LegacyParseType.Parse("alpha"); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "=")); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs index 2bfe94af8..bd96a8586 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using Server; using Server.Engines.AdvancedSearch; using Server.Items; @@ -48,6 +49,110 @@ public class AdvancedSearchWorkerTests } } + // End to end through Wake/Push/Sleep on real entities: the property test is compiled on the + // worker, memoized per type, and applied to items and mobiles alike. + [Fact] + public void Worker_PropertyTest_FiltersItemsAndMobiles() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter + { + FilterPropertyTest = true, + PropertyTest = "Hue > 0", + }; + + var plain = new Item(0x1); + var hued = new Item(0x1) { Hue = 42 }; + var huedToo = new Gold(1) { Hue = 7 }; + var mobile = new Mobile { Hue = 1002 }; + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(plain); + worker.Push(hued); + worker.Push(huedToo); + worker.Push(mobile); + worker.Sleep(); + + var matched = new HashSet(); + foreach (var r in results) + { + matched.Add(r.Entity); + } + + Assert.Equal(3, matched.Count); + Assert.Contains(hued, matched); + Assert.Contains(huedToo, matched); + Assert.Contains(mobile, matched); + Assert.DoesNotContain(plain, matched); + } + finally + { + plain.Delete(); + hued.Delete(); + huedToo.Delete(); + mobile.Delete(); + worker.Exit(); + } + } + + // The map boxes are independent checks. Ticking several used to reject everything, because + // each ticked map was applied as "must be on this map"; an entity on any ticked map passes. + [Fact] + public void Worker_SeveralMapsTicked_MatchesAnyOfThem() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter + { + FilterFelucca = true, + FilterTrammel = true, + FilterInternalMap = true, + HideValidInternalMap = false, + }; + + var fel = new Item(0x1); + fel.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + var tram = new Item(0x1); + tram.MoveToWorld(new Point3D(1000, 1000, 0), Map.Trammel); + var internalItem = new Item(0x1); // starts on Map.Internal + var malas = new Item(0x1); + malas.MoveToWorld(new Point3D(1000, 1000, 0), Map.Malas); + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(fel); + worker.Push(tram); + worker.Push(internalItem); + worker.Push(malas); + worker.Sleep(); + + var matched = new HashSet(); + foreach (var r in results) + { + matched.Add(r.Entity); + } + + Assert.Contains(fel, matched); + Assert.Contains(tram, matched); + Assert.Contains(internalItem, matched); + Assert.DoesNotContain(malas, matched); + } + finally + { + fel.Delete(); + tram.Delete(); + internalItem.Delete(); + malas.Delete(); + worker.Exit(); + } + } + [Fact] public void Worker_DeletedEntity_IsSkipped() { diff --git a/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs index 8709f0d14..7a37cd4f9 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs @@ -95,11 +95,9 @@ namespace Server.Commands.Generic parsed.Sort((a, b) => a.Order - b.Order); - AssemblyEmitter emitter = null; - foreach (var update in parsed) { - update.Optimize(from, baseType, ref emitter); + update.Optimize(from, baseType); } if (size != args.Length) @@ -129,7 +127,7 @@ namespace Server.Commands.Generic public int Order => Info.Order; - public virtual void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public virtual void Optimize(Mobile from, Type baseType) { } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs index 6f5af48d4..fecf0ee2e 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs @@ -1,7 +1,6 @@ using System; -using System.Globalization; +using System.Linq.Expressions; using System.Reflection; -using System.Reflection.Emit; namespace Server.Commands.Generic { @@ -12,209 +11,17 @@ namespace Server.Commands.Generic public interface ICondition { - // Invoked during the constructor - void Construct(TypeBuilder typeBuilder, ILGenerator il, int index); - - // Target object will be loaded on the stack - void Compile(MethodEmitter emitter); + // `target` is the object under test, already cast to the conditional's type (so it is + // null when the cast failed -- TypeCondition, always first, is what rejects that). + Expression Build(ParameterExpression target); } public sealed class TypeCondition : ICondition { public static TypeCondition Default = new(); - void ICondition.Construct(TypeBuilder typeBuilder, ILGenerator il, int index) - { - } - - void ICondition.Compile(MethodEmitter emitter) - { - // The object was safely cast to be the conditionals type - // If it's null, then the type cast didn't work... - - emitter.LoadNull(); - emitter.Compare(OpCodes.Ceq); - emitter.LogicalNot(); - } - } - - public sealed class PropertyValue - { - public PropertyValue(Type type, object value) - { - Type = type; - Value = value; - } - - public Type Type { get; } - - public object Value { get; private set; } - - public FieldInfo Field { get; private set; } - - public bool HasField => Field != null; - - public void Load(MethodEmitter method) - { - if (Field != null) - { - method.LoadArgument(0); - method.LoadField(Field); - } - else if (Value == null) - { - method.LoadNull(Type); - } - else - { - if (Value is int i) - { - method.Load(i); - } - else if (Value is long l) - { - method.Load(l); - } - else if (Value is float f) - { - method.Load(f); - } - else if (Value is double d) - { - method.Load(d); - } - else if (Value is char c) - { - method.Load(c); - } - else if (Value is bool b) - { - method.Load(b); - } - else if (Value is string s) - { - method.Load(s); - } - else if (Value is Enum e) - { - method.Load(e); - } - else - { - throw new InvalidOperationException("Unrecognized comparison value."); - } - } - } - - public void Acquire(TypeBuilder typeBuilder, ILGenerator il, string fieldName) - { - if (Value is not string toParse) - { - return; - } - - if (!Type.IsValueType && toParse == "null") - { - Value = null; - } - else if (Type == typeof(string)) - { - if (toParse == @"@""null""") - { - toParse = "null"; - } - - Value = toParse; - } - else if (Type.IsEnum) - { - Value = Enum.Parse(Type, toParse, true); - } - else if (Type == typeof(bool)) - { - Value = bool.Parse(toParse); - } - else - { - MethodInfo parseMethod; - object[] parseArgs; - - var parseNumber = Type.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringNumericParamTypes, - null - ); - - if (parseNumber != null) - { - var style = NumberStyles.Integer; - - if (toParse.InsensitiveStartsWith("0x")) - { - style = NumberStyles.HexNumber; - toParse = toParse[2..]; - } - - parseMethod = parseNumber; - parseArgs = new object[] { toParse, style }; - } - else - { - var parseGeneral = Type.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringParamTypes, - null - ); - - parseMethod = parseGeneral; - parseArgs = new object[] { toParse, null }; - } - - if (parseMethod != null) - { - Value = parseMethod.Invoke(null, parseArgs); - - if (!Type.IsPrimitive) - { - Field = typeBuilder.DefineField( - fieldName, - Type, - FieldAttributes.Private | FieldAttributes.InitOnly - ); - - il.Emit(OpCodes.Ldarg_0); - - il.Emit(OpCodes.Ldstr, toParse); - - if (parseArgs.Length == 2) // dirty evil hack :-( - { - if (parseArgs[1]?.GetType() == typeof(NumberStyles)) - { - il.Emit(OpCodes.Ldc_I4, (int)parseArgs[1]); - } - else - { - // IFormatProvider for `IParsable.Parse()` method. - il.Emit(OpCodes.Ldnull); - } - } - - il.Emit(OpCodes.Call, parseMethod); - il.Emit(OpCodes.Stfld, Field); - } - } - else - { - throw new InvalidOperationException( - $"Unable to convert string \"{Value}\" into type '{Type}'." - ); - } - } - } + Expression ICondition.Build(ParameterExpression target) => + Expression.ReferenceNotEqual(target, Expression.Constant(null, target.Type)); } public abstract class PropertyCondition : ICondition @@ -228,9 +35,22 @@ namespace Server.Commands.Generic m_Not = not; } - public abstract void Construct(TypeBuilder typeBuilder, ILGenerator il, int index); + public abstract Expression Build(ParameterExpression target); - public abstract void Compile(MethodEmitter emitter); + // A binding like Message.Number dereferences Message first, and Message is null on most + // objects a sweep walks. That is "no match" rather than a crash -- and it stays "no match" + // under negation, so the guard wraps the test after `not` has been applied to it. + protected Expression Guarded(ParameterExpression target, Func test) => + PropertyExpressions.Chain( + target, + m_Property, + value => + { + var result = test(value); + return m_Not ? Expression.Not(result) : result; + }, + Expression.Constant(false) + ); } public enum StringOperator @@ -247,125 +67,62 @@ namespace Server.Commands.Generic { private readonly bool m_IgnoreCase; private readonly StringOperator m_Operator; - private readonly PropertyValue m_Value; + private readonly object m_Value; public StringCondition(Property property, bool not, StringOperator op, object value, bool ignoreCase) : base(property, not) { m_Operator = op; - m_Value = new PropertyValue(property.Type, value); - + m_Value = value; m_IgnoreCase = ignoreCase; } - public override void Construct(TypeBuilder typeBuilder, ILGenerator il, int index) + public override Expression Build(ParameterExpression target) { - m_Value.Acquire(typeBuilder, il, $"v{index}"); - } - - public override void Compile(MethodEmitter emitter) - { - var inverse = false; - - var type = m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers); - string methodName; - - switch (m_Operator) + if (m_Property.Type != typeof(string)) { - case StringOperator.NotEqual: - { - inverse = true; - goto case StringOperator.Equal; - } - case StringOperator.Equal: - { - methodName = m_IgnoreCase ? "InsensitiveEquals" : "EqualsOrdinal"; - break; - } - - case StringOperator.Contains: - { - methodName = m_IgnoreCase ? "InsensitiveContains" : "ContainsOrdinal"; - break; - } - - case StringOperator.StartsWith: - { - methodName = m_IgnoreCase ? "InsensitiveStartsWith" : "StartsWithOrdinal"; - break; - } - - case StringOperator.EndsWith: - { - methodName = m_IgnoreCase ? "InsensitiveEndsWith" : "EndsWithOrdinal"; - break; - } - - default: - { - throw new InvalidOperationException("Invalid string comparison operator."); - } + throw new InvalidOperationException("String operators require a string property."); } - if (m_Operator is StringOperator.Equal or StringOperator.NotEqual) + var inverse = m_Operator == StringOperator.NotEqual; + + var methodName = m_Operator switch { - emitter.BeginCall( - type.GetMethod( - methodName, - BindingFlags.Public | BindingFlags.Static, - null, - [typeof(string), typeof(string)], - null - ) - ); + StringOperator.Equal or StringOperator.NotEqual => m_IgnoreCase ? "InsensitiveEquals" : "EqualsOrdinal", + StringOperator.Contains => m_IgnoreCase ? "InsensitiveContains" : "ContainsOrdinal", + StringOperator.StartsWith => m_IgnoreCase ? "InsensitiveStartsWith" : "StartsWithOrdinal", + StringOperator.EndsWith => m_IgnoreCase ? "InsensitiveEndsWith" : "EndsWithOrdinal", + _ => throw new InvalidOperationException("Invalid string comparison operator.") + }; - emitter.Chain(m_Property); - m_Value.Load(emitter); + var helper = (m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers)).GetMethod( + methodName, + BindingFlags.Public | BindingFlags.Static, + null, + [typeof(string), typeof(string)], + null + ); - emitter.FinishCall(); - } - else - { - var notNull = emitter.CreateLabel(); - var moveOn = emitter.CreateLabel(); + var constant = PropertyExpressions.Constant(typeof(string), m_Value); - var temp = emitter.AcquireTemp(m_Property.Type); + return Guarded( + target, + value => + { + Expression test = Expression.Call(helper, value, constant); - emitter.Chain(m_Property); + // The equality helpers handle a null of their own; the rest need the guard. + if (m_Operator is not (StringOperator.Equal or StringOperator.NotEqual)) + { + test = Expression.AndAlso( + Expression.ReferenceNotEqual(value, Expression.Constant(null, typeof(string))), + test + ); + } - emitter.StoreLocal(temp); - emitter.LoadLocal(temp); - - emitter.BranchIfTrue(notNull); - - emitter.Load(false); - emitter.Pop(); - emitter.Branch(moveOn); - - emitter.MarkLabel(notNull); - emitter.LoadLocal(temp); - - emitter.BeginCall( - type.GetMethod( - methodName, - BindingFlags.Public | BindingFlags.Static, - null, - [typeof(string), typeof(string)], - null - ) - ); - - m_Value.Load(emitter); - - emitter.FinishCall(); - - emitter.MarkLabel(moveOn); - } - - if (m_Not != inverse) - { - emitter.LogicalNot(); - } + return inverse ? Expression.Not(test) : test; + } + ); } } @@ -382,219 +139,110 @@ namespace Server.Commands.Generic public sealed class ComparisonCondition : PropertyCondition { private readonly ComparisonOperator m_Operator; - private readonly PropertyValue m_Value; + private readonly object m_Value; public ComparisonCondition(Property property, bool not, ComparisonOperator op, object value) : base(property, not) { m_Operator = op; - m_Value = new PropertyValue(property.Type, value); + m_Value = value; } - public override void Construct(TypeBuilder typeBuilder, ILGenerator il, int index) + public override Expression Build(ParameterExpression target) { - m_Value.Acquire(typeBuilder, il, $"v{index}"); - } + var constant = PropertyExpressions.Constant(m_Property.Type, m_Value); - public override void Compile(MethodEmitter emitter) - { - emitter.Chain(m_Property); - - var inverse = false; - - var couldCompare = - emitter.CompareTo(1, () => { m_Value.Load(emitter); }); - - if (couldCompare) - { - emitter.Load(0); - - switch (m_Operator) + return Guarded( + target, + value => { - case ComparisonOperator.Equal: - { - emitter.Compare(OpCodes.Ceq); - break; - } + if (PropertyExpressions.TryRelational(value, constant, m_Operator, out var test)) + { + return test; + } - case ComparisonOperator.NotEqual: - { - emitter.Compare(OpCodes.Ceq); - inverse = true; - break; - } - - case ComparisonOperator.Greater: - { - emitter.Compare(OpCodes.Cgt); - break; - } - - case ComparisonOperator.GreaterEqual: - { - emitter.Compare(OpCodes.Clt); - inverse = true; - break; - } - - case ComparisonOperator.Lesser: - { - emitter.Compare(OpCodes.Clt); - break; - } - - case ComparisonOperator.LesserEqual: - { - emitter.Compare(OpCodes.Cgt); - inverse = true; - break; - } - - default: - { - throw new InvalidOperationException("Invalid comparison operator."); - } + // This type is -not- comparable. We can only support == and != operations. + return m_Operator switch + { + ComparisonOperator.Equal => PropertyExpressions.ValueEquals(value, constant), + ComparisonOperator.NotEqual => Expression.Not(PropertyExpressions.ValueEquals(value, constant)), + ComparisonOperator.Greater or ComparisonOperator.GreaterEqual + or ComparisonOperator.Lesser or ComparisonOperator.LesserEqual => + throw new InvalidOperationException("Property does not support relational comparisons."), + _ => throw new InvalidOperationException("Invalid operator.") + }; } - } - else - { - // This type is -not- comparable - // We can only support == and != operations - - m_Value.Load(emitter); - - switch (m_Operator) - { - case ComparisonOperator.Equal: - { - emitter.Compare(OpCodes.Ceq); - break; - } - - case ComparisonOperator.NotEqual: - { - emitter.Compare(OpCodes.Ceq); - inverse = true; - break; - } - - case ComparisonOperator.Greater: - case ComparisonOperator.GreaterEqual: - case ComparisonOperator.Lesser: - case ComparisonOperator.LesserEqual: - { - throw new InvalidOperationException("Property does not support relational comparisons."); - } - - default: - { - throw new InvalidOperationException("Invalid operator."); - } - } - } - - if (m_Not != inverse) - { - emitter.LogicalNot(); - } + ); } } public static class ConditionalCompiler { - public static IConditional Compile(AssemblyEmitter assembly, Type objectType, ICondition[] conditions, int index) + private sealed class CompiledConditional : IConditional { - var typeBuilder = assembly.DefineType( - $"__conditional{index}", - TypeAttributes.Public, - typeof(object) - ); - { - var ctor = typeBuilder.DefineConstructor( - MethodAttributes.Public, - CallingConventions.Standard, - Type.EmptyTypes - ); + private readonly Func _verify; - var il = ctor.GetILGenerator(); + public CompiledConditional(Func verify) => _verify = verify; - // : base() - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)); + public bool Verify(object obj) => _verify(obj); + } - for (var i = 0; i < conditions.Length; ++i) + /// + /// Compiles a conjunction of conditions over into a single + /// delegate. The conditions short-circuit left to right, so + /// comes first and the rest can assume a non-null, correctly typed target. + /// + public static IConditional Compile(Type objectType, ICondition[] conditions) => + new CompiledConditional(Build(objectType, conditions).Compile()); + + public static Expression> Build(Type objectType, ICondition[] conditions) => + Lambda(objectType, target => Conjunction(target, conditions)); + + /// + /// A disjunction of conjunctions -- (a and b) or (c and d) -- as one lambda, for + /// callers that would otherwise compile every group separately and loop over them. + /// + public static Expression> Build(Type objectType, ICondition[][] groups) => + Lambda( + objectType, + target => { - conditions[i].Construct(typeBuilder, il, i); - } + Expression body = groups.Length > 0 ? Conjunction(target, groups[0]) : Expression.Constant(false); - // return; - il.Emit(OpCodes.Ret); - } - - typeBuilder.AddInterfaceImplementation(typeof(IConditional)); - - MethodBuilder compareMethod; - { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Verify", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(bool), - /* params */ - new[] { typeof(object) } - ); - - var obj = emitter.CreateLocal(objectType); - var eq = emitter.CreateLocal(typeof(bool)); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(obj); - - var done = emitter.CreateLabel(); - - for (var i = 0; i < conditions.Length; ++i) - { - if (i > 0) + for (var i = 1; i < groups.Length; ++i) { - emitter.LoadLocal(eq); - - emitter.BranchIfFalse(done); + body = Expression.OrElse(body, Conjunction(target, groups[i])); } - emitter.LoadLocal(obj); - - conditions[i].Compile(emitter); - - emitter.StoreLocal(eq); + return body; } + ); - emitter.MarkLabel(done); + private static Expression Conjunction(ParameterExpression target, ICondition[] conditions) + { + Expression body = conditions.Length > 0 ? conditions[0].Build(target) : Expression.Constant(true); - emitter.LoadLocal(eq); - - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IConditional).GetMethod( - "Verify", - new[] - { - typeof(object) - } - ) - ); - - compareMethod = emitter.Method; + for (var i = 1; i < conditions.Length; ++i) + { + body = Expression.AndAlso(body, conditions[i].Build(target)); } - var conditionalType = typeBuilder.CreateType(); + return body; + } - return conditionalType.CreateInstance(); + private static Expression> Lambda(Type objectType, Func body) + { + var obj = Expression.Parameter(typeof(object), "obj"); + var target = Expression.Variable(objectType, "target"); + + return Expression.Lambda>( + Expression.Block( + [target], + Expression.Assign(target, Expression.TypeAs(obj, objectType)), + body(target) + ), + obj + ); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs index 9e96ff2f7..7d80199b7 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs @@ -1,252 +1,95 @@ using System; using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; +using System.Linq.Expressions; namespace Server.Commands.Generic { public static class DistinctCompiler { - public static IComparer Compile(AssemblyEmitter assembly, Type objectType, Property[] props) + private sealed class DistinctComparer : IComparer, IEqualityComparer { - var typeBuilder = assembly.DefineType( - "__distinct", - TypeAttributes.Public, - typeof(object) + private readonly Comparison _compare; + private readonly Func _hash; + + public DistinctComparer(Comparison compare, Func hash) + { + _compare = compare; + _hash = hash; + } + + public int Compare(T x, T y) => _compare(x, y); + + public bool Equals(T x, T y) => _compare(x, y) == 0; + + public int GetHashCode(T obj) => _hash(obj); + } + + /// + /// A comparer that treats two objects as the same when every one of + /// reads equal on both. Ordering is the sort compiler's, all + /// ascending, so the result doubles as an . + /// + public static IComparer Compile(Type objectType, Property[] props) + { + var signs = new int[props.Length]; + Array.Fill(signs, 1); + + return new DistinctComparer( + SortCompiler.Build(objectType, props, signs).Compile(), + BuildHash(objectType, props).Compile() ); + } + + // XOR of each property's hash; a null reference hashes to 0 and an int hashes to itself. + public static Expression> BuildHash(Type objectType, Property[] props) + { + var arg = Expression.Parameter(typeof(T), "obj"); + var target = Expression.Variable(objectType, "target"); + + Expression hash = Expression.Constant(0); + + for (var i = 0; i < props.Length; ++i) { - var ctor = typeBuilder.DefineConstructor( - MethodAttributes.Public, - CallingConventions.Standard, - Type.EmptyTypes - ); + var part = HashOf(target, props[i]); - var il = ctor.GetILGenerator(); - - // : base() - il.Emit(OpCodes.Ldarg_0); - il.Emit( - OpCodes.Call, - typeof(T).GetConstructor(Type.EmptyTypes) ?? - throw new Exception($"Could not find empty constructor for type {typeof(T).FullName}") - ); - - // return; - il.Emit(OpCodes.Ret); + hash = i == 0 ? part : Expression.ExclusiveOr(hash, part); } - typeBuilder.AddInterfaceImplementation(typeof(IComparer)); + return Expression.Lambda>( + Expression.Block( + [target], + Expression.Assign(target, Expression.TypeAs(arg, objectType)), + hash + ), + arg + ); + } - MethodBuilder compareMethod; + private static Expression HashOf(Expression target, Property prop) + { + var read = PropertyExpressions.ChainOrDefault(target, prop); + var type = prop.Type; + + if (type == typeof(int)) { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Compare", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(int), - /* params */ - new[] { typeof(T), typeof(T) } - ); - - var a = emitter.CreateLocal(objectType); - var b = emitter.CreateLocal(objectType); - - var v = emitter.CreateLocal(typeof(int)); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(a); - - emitter.LoadArgument(2); - emitter.CastAs(objectType); - emitter.StoreLocal(b); - - emitter.Load(0); - emitter.StoreLocal(v); - - var end = emitter.CreateLabel(); - - for (var i = 0; i < props.Length; ++i) - { - if (i > 0) - { - emitter.LoadLocal(v); - emitter.BranchIfTrue(end); - } - - var prop = props[i]; - - emitter.LoadLocal(a); - emitter.Chain(prop); - - var couldCompare = - emitter.CompareTo( - 1, - () => - { - emitter.LoadLocal(b); - emitter.Chain(prop); - } - ); - - if (!couldCompare) - { - throw new InvalidOperationException("Property is not comparable."); - } - - emitter.StoreLocal(v); - } - - emitter.MarkLabel(end); - - emitter.LoadLocal(v); - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IComparer).GetMethod( - "Compare", - new[] - { - typeof(T), - typeof(T) - } - ) ?? throw new Exception($"No Compare method found for type {typeof(T).FullName}") - ); - - compareMethod = emitter.Method; + return read; } - typeBuilder.AddInterfaceImplementation(typeof(IEqualityComparer)); + var value = Expression.Variable(type, prop.Binding); + var getHashCode = type.GetMethod("GetHashCode", Type.EmptyTypes) ?? typeof(object).GetMethod("GetHashCode", Type.EmptyTypes)!; + + Expression hash = Expression.Call(value, getHashCode); + + if (!type.IsValueType) { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Equals", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(bool), - /* params */ - new[] { typeof(T), typeof(T) } - ); - - emitter.Generator.Emit(OpCodes.Ldarg_0); - emitter.Generator.Emit(OpCodes.Ldarg_1); - emitter.Generator.Emit(OpCodes.Ldarg_2); - - emitter.Generator.Emit(OpCodes.Call, compareMethod); - - emitter.Generator.Emit(OpCodes.Ldc_I4_0); - - emitter.Generator.Emit(OpCodes.Ceq); - - emitter.Generator.Emit(OpCodes.Ret); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IEqualityComparer).GetMethod( - "Equals", - new[] - { - typeof(T), - typeof(T) - } - ) ?? throw new Exception($"No Equals method found for type {typeof(T).FullName}") + hash = Expression.Condition( + Expression.ReferenceNotEqual(value, Expression.Constant(null, type)), + hash, + Expression.Constant(0) ); } - { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "GetHashCode", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(int), - /* params */ - new[] { typeof(T) } - ); - - var obj = emitter.CreateLocal(objectType); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(obj); - - for (var i = 0; i < props.Length; ++i) - { - var prop = props[i]; - - emitter.LoadLocal(obj); - emitter.Chain(prop); - - var active = emitter.Active; - - var getHashCode = active.GetMethod("GetHashCode", Type.EmptyTypes) - ?? typeof(T).GetMethod("GetHashCode", Type.EmptyTypes); - - if (active != typeof(int)) - { - if (!active.IsValueType) - { - var value = emitter.AcquireTemp(active); - - var valueNotNull = emitter.CreateLabel(); - var done = emitter.CreateLabel(); - - emitter.StoreLocal(value); - emitter.LoadLocal(value); - - emitter.BranchIfTrue(valueNotNull); - - emitter.Load(0); - emitter.Pop(typeof(int)); - - emitter.Branch(done); - - emitter.MarkLabel(valueNotNull); - - emitter.LoadLocal(value); - emitter.Call(getHashCode); - - emitter.ReleaseTemp(value); - - emitter.MarkLabel(done); - } - else - { - emitter.Call(getHashCode); - } - } - - if (i > 0) - { - emitter.Xor(); - } - } - - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IEqualityComparer).GetMethod( - "GetHashCode", - new[] - { - typeof(T) - } - ) ?? throw new Exception($"No GetHashCode method found for type {typeof(T).FullName}") - ); - } - - var comparerType = typeBuilder.CreateType(); - - return comparerType.CreateInstance>(); + return Expression.Block([value], Expression.Assign(value, read), hash); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs new file mode 100644 index 000000000..68f30447c --- /dev/null +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs @@ -0,0 +1,407 @@ +using System; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; + +namespace Server.Commands.Generic; + +/// +/// Expression-tree fragments over a bound chain, shared by the +/// conditional, sort and distinct compilers. Everything here builds an ; +/// the compilers assemble those into a lambda and hand Compile() the codegen. +/// +public static class PropertyExpressions +{ + private static readonly MethodInfo _objectEquals = typeof(object).GetMethod( + nameof(object.Equals), + BindingFlags.Public | BindingFlags.Static, + [typeof(object), typeof(object)] + )!; + + /// + /// Walks a property binding. A binding of more than one property (Message.Number) + /// dereferences each link in turn, and any link but the last can legitimately be null -- an + /// unset TextDefinition, an unparented item. Each reference-typed intermediate link is stored + /// once and null-checked; a null there yields in place of + /// whatever would have built from the final link. + /// + public static Expression Chain( + Expression target, + Property prop, + Func onValue, + Expression whenUnreadable + ) => ChainFrom(target, prop.Chain, 0, onValue, whenUnreadable); + + private static Expression ChainFrom( + Expression current, + PropertyInfo[] chain, + int index, + Func onValue, + Expression whenUnreadable + ) + { + var link = Expression.Property(current, chain[index]); + + // The last link is the value being tested, so a null there is the caller's business. + if (index == chain.Length - 1) + { + return onValue(link); + } + + if (link.Type.IsValueType) + { + return ChainFrom(link, chain, index + 1, onValue, whenUnreadable); + } + + var local = Expression.Variable(link.Type, chain[index].Name); + + return Expression.Block( + [local], + Expression.Assign(local, link), + Expression.Condition( + Expression.ReferenceNotEqual(local, Expression.Constant(null, local.Type)), + ChainFrom(local, chain, index + 1, onValue, whenUnreadable), + whenUnreadable + ) + ); + } + + /// + /// Walks a binding for a caller that has no way to express "no match" -- ordering and + /// grouping, where the value itself is the answer rather than a yes or no. An unreadable + /// link yields default(T), which is what a null link along the way amounts to; the + /// comparers these feed already handle a null value. + /// + public static Expression ChainOrDefault(Expression target, Property prop) => + Chain(target, prop, static value => value, Expression.Default(prop.Type)); + + /// + /// Equality for the "not comparable" path, which supports only == and !=. Reference equality + /// would miss a type whose equality is by value -- among them -- + /// so this is static object.Equals, which honors the override and is null-safe on + /// either side. Value types box; they only reach here when they have no CompareTo. + /// + public static Expression ValueEquals(Expression a, Expression b) => + Expression.Call(_objectEquals, Box(a), Box(b)); + + private static Expression Box(Expression e) => + e.Type == typeof(object) ? e : Expression.Convert(e, typeof(object)); + + /// + /// A boolean test of against . Integral primitives + /// and enums compare with the operator itself -- on the unsigned types as unsigned; nothing + /// here widens to a signed type. Everything else goes through and + /// tests the sign of the result. Nullable<T> is lifted either way, as C# lifts it: + /// two nulls are equal, a null and a value are unequal, and a null satisfies no relation. + /// (A null reference keeps the ordering gives it.) False + /// when the type has no CompareTo at all, in which case only equality is meaningful. + /// + public static bool TryRelational(Expression a, Expression b, ComparisonOperator op, out Expression test) + { + var type = a.Type; + var underlying = Nullable.GetUnderlyingType(type); + var nonNullable = underlying ?? type; + + if (underlying != null && !underlying.IsEnum && !IsIntegral(underlying)) + { + return TryLiftedRelational(a, b, op, out test); + } + + if (nonNullable.IsEnum) + { + // Equal/NotEqual are defined on enums; the relational operators are not, so those + // read the underlying integer. Convert lifts over Nullable on its own. + if (op is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual)) + { + var integer = Enum.GetUnderlyingType(nonNullable); + + if (underlying != null) + { + integer = typeof(Nullable<>).MakeGenericType(integer); + } + + a = Expression.Convert(a, integer); + b = Expression.Convert(b, integer); + } + + test = Relational(a, b, op); + return true; + } + + if (IsIntegral(nonNullable)) + { + test = Relational(a, b, op); + return true; + } + + if (!TryCompare(a, b, 1, out var comparison)) + { + test = null; + return false; + } + + test = Relational(comparison, Expression.Constant(0), op); + return true; + } + + // Nullable over a type that compares through CompareTo: the values compare when both are + // present, and the HasValue flags decide otherwise, the way the language lifts an operator. + private static bool TryLiftedRelational(Expression a, Expression b, ComparisonOperator op, out Expression test) + { + var left = Expression.Variable(a.Type, "left"); + var right = Expression.Variable(a.Type, "right"); + + var couldCompare = TryCompareValues( + Expression.Property(left, "Value"), + Expression.Property(right, "Value"), + 1, + out var comparison + ); + + if (!couldCompare) + { + test = null; + return false; + } + + var leftHasValue = Expression.Property(left, "HasValue"); + var rightHasValue = Expression.Property(right, "HasValue"); + var both = Expression.AndAlso(leftHasValue, rightHasValue); + var relation = Relational(comparison, Expression.Constant(0), op); + + Expression lifted = op switch + { + ComparisonOperator.Equal => Expression.Condition(both, relation, Expression.Equal(leftHasValue, rightHasValue)), + ComparisonOperator.NotEqual => Expression.Condition(both, relation, Expression.NotEqual(leftHasValue, rightHasValue)), + _ => Expression.AndAlso(both, relation) + }; + + test = Expression.Block( + [left, right], + Expression.Assign(left, a), + Expression.Assign(right, b), + lifted + ); + + return true; + } + + private static Expression Relational(Expression a, Expression b, ComparisonOperator op) => + op switch + { + ComparisonOperator.Equal => Expression.Equal(a, b), + ComparisonOperator.NotEqual => Expression.NotEqual(a, b), + ComparisonOperator.Greater => Expression.GreaterThan(a, b), + ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(a, b), + ComparisonOperator.Lesser => Expression.LessThan(a, b), + ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(a, b), + _ => throw new InvalidOperationException("Invalid comparison operator.") + }; + + private static bool IsIntegral(Type type) => + type == typeof(int) || type == typeof(long) || type == typeof(uint) || type == typeof(ulong) + || type == typeof(short) || type == typeof(ushort) || type == typeof(byte) || type == typeof(sbyte); + + /// + /// An int-valued comparison of against + /// with CompareTo semantics, multiplied by . A null on either + /// side of a reference or nullable type is handled here rather than in the callee: + /// null.CompareTo(null) = 0, real.CompareTo(null) = -sign, + /// null.CompareTo(real) = +sign. False when the type has no CompareTo. + /// + public static bool TryCompare(Expression a, Expression b, int sign, out Expression comparison) + { + var type = a.Type; + + // Both sides are read more than once below; pin them so a chained binding is walked once. + var left = Expression.Variable(type, "left"); + var right = Expression.Variable(type, "right"); + + if (!TryCompareValues(left, right, sign, out var body)) + { + comparison = null; + return false; + } + + comparison = Expression.Block( + [left, right], + Expression.Assign(left, a), + Expression.Assign(right, b), + body + ); + + return true; + } + + private static bool TryCompareValues(Expression a, Expression b, int sign, out Expression comparison) + { + var type = a.Type; + var underlying = Nullable.GetUnderlyingType(type); + + if (underlying != null) + { + if (!TryCompareValues(Expression.Property(a, "Value"), Expression.Property(b, "Value"), sign, out var inner)) + { + comparison = null; + return false; + } + + comparison = NullAware(Expression.Property(a, "HasValue"), Expression.Property(b, "HasValue"), inner, sign); + return true; + } + + if (type.IsEnum) + { + var integer = Enum.GetUnderlyingType(type); + + return TryCompareValues(Expression.Convert(a, integer), Expression.Convert(b, integer), sign, out comparison); + } + + var compareTo = FindCompareTo(type); + + if (compareTo == null) + { + comparison = null; + return false; + } + + var parameterType = compareTo.GetParameters()[0].ParameterType; + var argument = parameterType == type ? b : Expression.Convert(b, parameterType); + + Expression call = Expression.Call(a, compareTo, argument); + + if (sign == -1) + { + call = Expression.Negate(call); + } + + if (type.IsValueType) + { + comparison = call; + return true; + } + + var nil = Expression.Constant(null, type); + + comparison = NullAware(Expression.ReferenceNotEqual(a, nil), Expression.ReferenceNotEqual(b, nil), call, sign); + return true; + } + + private static Expression NullAware(Expression aHasValue, Expression bHasValue, Expression compare, int sign) => + Expression.Condition( + aHasValue, + Expression.Condition(bHasValue, compare, Expression.Constant(-sign)), + Expression.Condition(bHasValue, Expression.Constant(sign), Expression.Constant(0)) + ); + + private static MethodInfo FindCompareTo(Type type) + { + var compareTo = type.GetMethod("CompareTo", [type]); + + if (compareTo != null) + { + return compareTo; + } + + /* There's a scenario where we might be trying to use CompareTo on an interface + * which, while it doesn't explicitly implement CompareTo itself, is said to + * extend IComparable indirectly. The implementation is implicitly passed off + * to implementers, so the interface's own GetMethod("CompareTo") returns null. + */ + var ifaces = type.FindInterfaces( + static (iface, _) => iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IComparable<>), + null + ); + + for (var i = 0; i < ifaces.Length; ++i) + { + if (ifaces[i].GetGenericArguments()[0].IsAssignableFrom(type)) + { + return ifaces[i].GetMethod("CompareTo", [type]); + } + } + + return typeof(IComparable).IsAssignableFrom(type) + ? typeof(IComparable).GetMethod("CompareTo", [typeof(object)]) + : null; + } + + /// + /// The right-hand side of a condition as a typed constant. A string is parsed the way the + /// props gump would parse it: null for a reference or nullable type, @"null" + /// for the literal string, names for enums, hex with a 0x prefix for the numerics, + /// and the type's own static Parse for everything else. + /// + public static ConstantExpression Constant(Type type, object value) + { + if (value is string text) + { + value = Parse(type, text); + } + + return Expression.Constant(value, type); + } + + private static object Parse(Type type, string text) + { + var underlying = Nullable.GetUnderlyingType(type); + + if (text == "null" && (underlying != null || !type.IsValueType)) + { + return null; + } + + var target = underlying ?? type; + + if (target == typeof(string)) + { + return text == @"@""null""" ? "null" : text; + } + + if (target.IsEnum) + { + return Enum.Parse(target, text, true); + } + + if (target == typeof(bool)) + { + return bool.Parse(text); + } + + var parseNumber = target.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + null, + Types.ParseStringNumericParamTypes, + null + ); + + if (parseNumber != null) + { + var style = NumberStyles.Integer; + + if (text.InsensitiveStartsWith("0x")) + { + style = NumberStyles.HexNumber; + text = text[2..]; + } + + return parseNumber.Invoke(null, [text, style]); + } + + var parseGeneral = target.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + null, + Types.ParseStringParamTypes, + null + ); + + if (parseGeneral != null) + { + return parseGeneral.Invoke(null, [text, null]); + } + + throw new InvalidOperationException($"Unable to convert string \"{text}\" into type '{type}'."); + } +} diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs index a597ece98..812a6726a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; +using System.Linq.Expressions; namespace Server.Commands.Generic { @@ -47,120 +46,83 @@ namespace Server.Commands.Generic public static class SortCompiler { - public static IComparer Compile(AssemblyEmitter assembly, Type objectType, OrderInfo[] orders) + public static IComparer Compile(Type objectType, OrderInfo[] orders) { - var typeBuilder = assembly.DefineType( - "__sort", - TypeAttributes.Public, - typeof(T) + var properties = new Property[orders.Length]; + var signs = new int[orders.Length]; + + for (var i = 0; i < orders.Length; ++i) + { + properties[i] = orders[i].Property; + signs[i] = orders[i].Sign; + } + + return Comparer.Create(Build(objectType, properties, signs).Compile()); + } + + /// + /// A over , taken in order: the + /// first property that orders the two objects decides, each multiplied by its sign. Both + /// arguments are cast to first; the bindings are read from + /// that. + /// + public static Expression> Build(Type objectType, Property[] properties, int[] signs) + { + var x = Expression.Parameter(typeof(T), "x"); + var y = Expression.Parameter(typeof(T), "y"); + + var a = Expression.Variable(objectType, "a"); + var b = Expression.Variable(objectType, "b"); + + return Expression.Lambda>( + Expression.Block( + [a, b], + Expression.Assign(a, Expression.TypeAs(x, objectType)), + Expression.Assign(b, Expression.TypeAs(y, objectType)), + Ordered(a, b, properties, signs, 0) + ), + x, + y ); + } + + private static Expression Ordered(Expression a, Expression b, Property[] properties, int[] signs, int index) + { + if (index >= properties.Length) { - var ctor = typeBuilder.DefineConstructor( - MethodAttributes.Public, - CallingConventions.Standard, - Type.EmptyTypes - ); - - var il = ctor.GetILGenerator(); - - // : base() - il.Emit(OpCodes.Ldarg_0); - il.Emit( - OpCodes.Call, - typeof(T).GetConstructor(Type.EmptyTypes) ?? - throw new Exception($"Could not find empty constructor for type {typeof(T).FullName}") - ); - - // return; - il.Emit(OpCodes.Ret); + return Expression.Constant(0); } - typeBuilder.AddInterfaceImplementation(typeof(IComparer)); + var prop = properties[index]; + + var couldCompare = PropertyExpressions.TryCompare( + PropertyExpressions.ChainOrDefault(a, prop), + PropertyExpressions.ChainOrDefault(b, prop), + signs[index], + out var comparison + ); + + if (!couldCompare) { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Compare", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(int), - /* params */ - new[] { typeof(T), typeof(T) } - ); - - var a = emitter.CreateLocal(objectType); - var b = emitter.CreateLocal(objectType); - - var v = emitter.CreateLocal(typeof(int)); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(a); - - emitter.LoadArgument(2); - emitter.CastAs(objectType); - emitter.StoreLocal(b); - - emitter.Load(0); - emitter.StoreLocal(v); - - var end = emitter.CreateLabel(); - - for (var i = 0; i < orders.Length; ++i) - { - if (i > 0) - { - emitter.LoadLocal(v); - emitter.BranchIfTrue(end); - } - - var orderInfo = orders[i]; - - var prop = orderInfo.Property; - var sign = orderInfo.Sign; - - emitter.LoadLocal(a); - emitter.Chain(prop); - - var couldCompare = - emitter.CompareTo( - sign, - () => - { - emitter.LoadLocal(b); - emitter.Chain(prop); - } - ); - - if (!couldCompare) - { - throw new InvalidOperationException("Property is not comparable."); - } - - emitter.StoreLocal(v); - } - - emitter.MarkLabel(end); - - emitter.LoadLocal(v); - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IComparer).GetMethod( - "Compare", - new[] - { - typeof(T), - typeof(T) - } - ) ?? throw new Exception($"No Compare method found for type {typeof(T).FullName}") - ); + throw new InvalidOperationException("Property is not comparable."); } - var comparerType = typeBuilder.CreateType(); - return comparerType.CreateInstance>(); + if (index == properties.Length - 1) + { + return comparison; + } + + var v = Expression.Variable(typeof(int), "v"); + + return Expression.Block( + [v], + Expression.Assign(v, comparison), + Expression.Condition( + Expression.NotEqual(v, Expression.Constant(0)), + v, + Ordered(a, b, properties, signs, index + 1) + ) + ); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs index 2ed85ec1b..bbcb4215a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs @@ -21,7 +21,7 @@ namespace Server.Commands.Generic ExtensionInfo.Register(ExtInfo); } - public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public override void Optimize(Mobile from, Type baseType) { if (baseType == null) { @@ -34,9 +34,7 @@ namespace Server.Commands.Generic prop.CheckAccess(from); } - assembly ??= new AssemblyEmitter("__dynamic"); - - m_Comparer = DistinctCompiler.Compile(assembly, baseType, m_Properties.ToArray()); + m_Comparer = DistinctCompiler.Compile(baseType, m_Properties.ToArray()); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs index c81f8f160..adfbcfae8 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs @@ -20,7 +20,7 @@ namespace Server.Commands.Generic ExtensionInfo.Register(ExtInfo); } - public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public override void Optimize(Mobile from, Type baseType) { if (baseType == null) { @@ -33,9 +33,7 @@ namespace Server.Commands.Generic order.Property.CheckAccess(from); } - assembly ??= new AssemblyEmitter("__dynamic"); - - m_Comparer = SortCompiler.Compile(assembly, baseType, m_Orders.ToArray()); + m_Comparer = SortCompiler.Compile(baseType, m_Orders.ToArray()); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs index 47d8cd9a2..78f206483 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs @@ -15,14 +15,14 @@ namespace Server.Commands.Generic ExtensionInfo.Register(ExtInfo); } - public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public override void Optimize(Mobile from, Type baseType) { if (baseType == null) { throw new InvalidOperationException("Insanity."); } - Conditional.Compile(ref assembly); + Conditional.Compile(); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs b/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs index bbd64e822..750df728d 100644 --- a/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs +++ b/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs @@ -27,15 +27,13 @@ namespace Server.Commands.Generic public bool HasCompiled => m_Conditionals != null; - public void Compile(ref AssemblyEmitter emitter) + public void Compile() { - emitter ??= new AssemblyEmitter("__dynamic"); - m_Conditionals = new IConditional[m_Conditions.Length]; for (var i = 0; i < m_Conditionals.Length; ++i) { - m_Conditionals[i] = ConditionalCompiler.Compile(emitter, Type, m_Conditions[i], i); + m_Conditionals[i] = ConditionalCompiler.Compile(Type, m_Conditions[i]); } } @@ -48,9 +46,7 @@ namespace Server.Commands.Generic if (!HasCompiled) { - AssemblyEmitter emitter = null; - - Compile(ref emitter); + Compile(); } for (var i = 0; i < m_Conditionals.Length; ++i) diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs new file mode 100644 index 000000000..5eb42ac9e --- /dev/null +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using Server.Commands.Generic; + +namespace Server.Engines.AdvancedSearch; + +/// +/// Turns a property-test string from the Advanced Search gump into a compiled predicate. The +/// grammar is the gump's own -- ~ negates a leaf, @ is AND, | is OR and binds +/// looser, and the string operators double up (> is "starts with" on a string) -- but +/// each leaf becomes the same a where clause compiles, so there is +/// one comparison engine. A leaf that cannot be resolved or parsed is simply "no match". +/// +/// +/// Runs on the search workers, off the game loop: binding is reflection, compiling is +/// Expression.Compile, and neither touches game state. The one exception is a value that +/// names an entity by serial, which resolves through the world -- +/// the same read the previous per-entity evaluator made, now made once per type instead. +/// +public static class AdvancedSearchConditions +{ + // Runtime type -> its public readable instance properties, for the case-insensitive name scan. + private static readonly ConcurrentDictionary _properties = new(); + + private static readonly Func _never = static _ => false; + + /// + /// Per-search memo shared by every worker. Predicates are keyed twice: by the runtime type + /// seen, and by the type the predicate was actually compiled for -- the most derived type + /// that declares one of the properties -- so every subclass of Item that does not hide + /// Hue shares one compiled Hue = 5. + /// + public sealed class Cache + { + internal readonly ConcurrentDictionary> ByRuntimeType = new(); + internal readonly ConcurrentDictionary> ByCompiledType = new(); + } + + public static Func GetPredicate(Cache cache, Type runtimeType, string propertyTest) => + cache.ByRuntimeType.GetOrAdd( + runtimeType, + static (type, state) => Build(state.cache, type, state.propertyTest), + (cache, propertyTest) + ); + + /// Compiles without a cache. Test seam and one-off use. + public static Func Compile(Type runtimeType, string propertyTest) => + Build(new Cache(), runtimeType, propertyTest); + + private static Func Build(Cache cache, Type runtimeType, string propertyTest) + { + var groups = Parse(runtimeType, propertyTest, out var compiledType); + + if (groups == null) + { + return _never; + } + + return cache.ByCompiledType.GetOrAdd( + compiledType, + static (type, groups) => + { + try + { + return ConditionalCompiler.Build(type, groups).Compile(); + } + catch (Exception) + { + return _never; + } + }, + groups + ); + } + + // OR of ANDs, which is what splitting on '|' and then on '@' yields. A group with a dead leaf + // is dropped; no live group left means nothing can match, reported as null. + private static ICondition[][] Parse(Type runtimeType, string propertyTest, out Type compiledType) + { + Type mostDerived = null; + + var groups = new List(); + + foreach (var orPart in propertyTest.Split('|')) + { + var group = new List { TypeCondition.Default }; + var alive = true; + + foreach (var andPart in orPart.Split('@')) + { + var leaf = Leaf(runtimeType, andPart, out var declaringType); + + if (leaf == null) + { + alive = false; + break; + } + + group.Add(leaf); + + // Every declaring type is an ancestor of the runtime type (or the type itself), so + // they nest; the predicate is compiled for the most derived one any leaf needs and + // serves every runtime type that resolves the same properties. + if (mostDerived == null || declaringType.IsAssignableTo(mostDerived)) + { + mostDerived = declaringType; + } + } + + if (alive) + { + groups.Add(group.ToArray()); + } + } + + compiledType = mostDerived ?? runtimeType; + + return groups.Count > 0 ? groups.ToArray() : null; + } + + private static ICondition Leaf(Type runtimeType, ReadOnlySpan expression, out Type declaringType) + { + declaringType = runtimeType; + expression = expression.Trim(); + + if (expression.Length == 0) + { + return null; + } + + var negate = false; + + if (expression[0] == '~') + { + negate = true; + expression = expression[1..]; + } + + var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex); + + if (operatorSpan.Length == 0) + { + return null; + } + + var propertyName = expression[..operatorIndex].Trim(); + var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim(); + + if (valuePart.Length == 0) + { + return null; + } + + var chain = Resolve(runtimeType, propertyName); + + if (chain == null) + { + return null; + } + + declaringType = chain[0].DeclaringType!; + + var property = new Property(chain); + var type = property.Type; + var op = operatorSpan.ToString(); + var value = valuePart.ToString(); + + ICondition condition; + + if (type == typeof(string)) + { + condition = StringLeaf(property, negate, op, value); + } + else if (type == typeof(double) || type == typeof(float)) + { + condition = EpsilonLeaf(property, negate, op, value); + } + else + { + condition = ComparisonLeaf(property, negate, op, value); + } + + return condition != null && Probe(condition, runtimeType) ? condition : null; + } + + // A leaf the compiler rejects -- a relational operator on a type with no CompareTo -- is + // "no match" for that leaf, not an error for the whole search. + private static bool Probe(ICondition condition, Type runtimeType) + { + try + { + condition.Build(Expression.Parameter(runtimeType, "probe")); + return true; + } + catch (Exception) + { + return false; + } + } + + private static ICondition StringLeaf(Property property, bool negate, string op, string value) + { + var (stringOp, ignoreCase) = op switch + { + "=" or "==" => (StringOperator.Equal, false), + "!" or "!=" => (StringOperator.NotEqual, false), + ">" => (StringOperator.StartsWith, false), + "<" => (StringOperator.EndsWith, false), + "~" => (StringOperator.Contains, false), + "~>" => (StringOperator.StartsWith, true), + "~<" => (StringOperator.EndsWith, true), + "~~" => (StringOperator.Contains, true), + "~=" => (StringOperator.Equal, true), + "~!" => (StringOperator.NotEqual, true), + _ => ((StringOperator?)null, false) + }; + + if (stringOp == null) + { + return null; + } + + // `null` is the null string for equality, as it is in a where clause; for the substring + // operators, where a null needle means nothing, it is the four-letter word. + if (value == "null" && stringOp is not (StringOperator.Equal or StringOperator.NotEqual)) + { + value = @"@""null"""; + } + + return new StringCondition(property, negate, stringOp.Value, value, ignoreCase); + } + + private static ICondition EpsilonLeaf(Property property, bool negate, string op, string value) + { + var comparison = MapOperator(op); + + if (comparison == null) + { + return null; + } + + // A float property parses its value as a float first, so the widened constant carries the + // same rounding the property's own value does. + double parsed; + + if (property.Type == typeof(float)) + { + if (!float.TryParse(value, null, out var f)) + { + return null; + } + + parsed = f; + } + else if (!double.TryParse(value, null, out parsed)) + { + return null; + } + + return new EpsilonCondition(property, negate, comparison.Value, parsed, AdvancedSearchUtilities.CalculateEpsilon(value)); + } + + private static ICondition ComparisonLeaf(Property property, bool negate, string op, string value) + { + var comparison = MapOperator(op); + + if (comparison == null) + { + return null; + } + + var type = property.Type; + var underlying = Nullable.GetUnderlyingType(type); + object parsed; + + if (value == "null" && (underlying != null || !type.IsValueType)) + { + parsed = null; + } + else if (underlying == null && type == typeof(bool)) + { + // The gump accepts the switch words as well as the literals. + if (comparison is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual)) + { + return null; + } + + parsed = value.ToLowerInvariant() switch + { + "true" or "1" or "enabled" or "on" => true, + "false" or "0" or "disabled" or "off" => false, + _ => null + }; + + if (parsed == null) + { + return null; + } + } + else if (Types.TryParse(underlying ?? type, value, out parsed) != null) + { + return null; + } + + return new ComparisonCondition(property, negate, comparison.Value, parsed); + } + + private static ComparisonOperator? MapOperator(string op) => + op switch + { + "=" or "==" => ComparisonOperator.Equal, + "!" or "!=" => ComparisonOperator.NotEqual, + ">" => ComparisonOperator.Greater, + "<" => ComparisonOperator.Lesser, + ">=" => ComparisonOperator.GreaterEqual, + "<=" => ComparisonOperator.LesserEqual, + _ => null + }; + + // Case-insensitive, first readable match per link, the way the gump has always resolved a + // name. A dotted name walks into the property's type. + private static PropertyInfo[] Resolve(Type type, ReadOnlySpan name) + { + var count = name.Count('.') + 1; + var chain = new PropertyInfo[count]; + + for (var i = 0; i < count; ++i) + { + var dot = name.IndexOf('.'); + var segment = dot == -1 ? name : name[..dot]; + name = dot == -1 ? default : name[(dot + 1)..]; + + var found = Find(type, segment.Trim()); + + if (found == null) + { + return null; + } + + chain[i] = found; + type = found.PropertyType; + } + + return chain; + } + + private static PropertyInfo Find(Type type, ReadOnlySpan name) + { + var properties = _properties.GetOrAdd(type, static t => Readable(t)); + + for (var i = 0; i < properties.Length; ++i) + { + if (name.InsensitiveEquals(properties[i].Name)) + { + return properties[i]; + } + } + + return null; + } + + private static PropertyInfo[] Readable(Type type) + { + var all = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var readable = new List(all.Length); + + for (var i = 0; i < all.Length; ++i) + { + if (all[i].CanRead && all[i].GetIndexParameters().Length == 0) + { + readable.Add(all[i]); + } + } + + return readable.ToArray(); + } + + /// + /// A floating-point comparison with the tolerance the gump derives from the typed value: a + /// value with no decimal point compares to within 1E-10, one with ten or more decimals to + /// within the last digit typed. + /// + private sealed class EpsilonCondition : ICondition + { + private static readonly MethodInfo _abs = typeof(Math).GetMethod(nameof(Math.Abs), [typeof(double)])!; + + private readonly Property _property; + private readonly bool _not; + private readonly ComparisonOperator _operator; + private readonly double _value; + private readonly double _epsilon; + + public EpsilonCondition(Property property, bool not, ComparisonOperator op, double value, double epsilon) + { + _property = property; + _not = not; + _operator = op; + _value = value; + _epsilon = epsilon; + } + + public Expression Build(ParameterExpression target) => + PropertyExpressions.Chain( + target, + _property, + read => + { + var value = read.Type == typeof(double) ? read : Expression.Convert(read, typeof(double)); + var constant = Expression.Constant(_value); + var epsilon = Expression.Constant(_epsilon); + var distance = Expression.Call(_abs, Expression.Subtract(value, constant)); + + Expression test = _operator switch + { + ComparisonOperator.Equal => Expression.LessThan(distance, epsilon), + ComparisonOperator.NotEqual => Expression.GreaterThanOrEqual(distance, epsilon), + ComparisonOperator.Greater => Expression.GreaterThan(value, Expression.Add(constant, epsilon)), + ComparisonOperator.Lesser => Expression.LessThan(value, Expression.Subtract(constant, epsilon)), + ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(value, Expression.Subtract(constant, epsilon)), + ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(value, Expression.Add(constant, epsilon)), + _ => throw new InvalidOperationException("Invalid comparison operator.") + }; + + return _not ? Expression.Not(test) : test; + }, + Expression.Constant(false) + ); + } +} diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index 7f72c02b2..96c357fd0 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -767,11 +767,12 @@ public class AdvancedSearchGump : Gump var ignoreQueue = new ConcurrentQueue(); var results = new ConcurrentQueue(); + var predicates = new AdvancedSearchConditions.Cache(); var worldLocation = new WorldLocation(from.Location, from.Map); for (var i = 0; i < _threadWorkers.Length; i++) { - (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); + (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue, predicates); } var type = Filter.FilterType ? Filter.Type : null; diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index 5206477c9..8f0c9c1a5 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Reflection; using System.Threading; using Server.Items; using Server.Logging; @@ -21,7 +20,6 @@ namespace Server.Engines.AdvancedSearch; public class AdvancedSearchThreadWorker { private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchThreadWorker)); - private static readonly ConcurrentDictionary _propCache = new(); private readonly Thread _thread; private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working @@ -33,6 +31,7 @@ public class AdvancedSearchThreadWorker private ConcurrentQueue _ignoreQueue; private WorldLocation _worldLocation; private AdvancedSearchFilter _filter; + private AdvancedSearchConditions.Cache _predicates; public AdvancedSearchThreadWorker() { @@ -46,17 +45,23 @@ public class AdvancedSearchThreadWorker _thread.Start(this); } + /// + /// Compiled property-test memo for this search. Shared across the workers so a type is + /// compiled once per search rather than once per worker; a lone worker may leave it null. + /// public void Wake( WorldLocation worldLocation, AdvancedSearchFilter filter, ConcurrentQueue results, - ConcurrentQueue ignoreQueue + ConcurrentQueue ignoreQueue, + AdvancedSearchConditions.Cache predicates = null ) { _worldLocation = worldLocation; _filter = filter; _ignoreQueue = ignoreQueue; _results = results; + _predicates = predicates ?? new AdvancedSearchConditions.Cache(); _startEvent.Set(); } @@ -108,6 +113,7 @@ public class AdvancedSearchThreadWorker { worker._results = null; worker._filter = null; + worker._predicates = null; // a compiled constant may pin an entity resolved by serial break; } else @@ -158,15 +164,7 @@ public class AdvancedSearchThreadWorker return null; } - // Check for valid map - if (_filter.FilterFelucca && entity.Map != Map.Felucca || - _filter.FilterTrammel && entity.Map != Map.Trammel || - _filter.FilterIlshenar && entity.Map != Map.Ilshenar || - _filter.FilterMalas && entity.Map != Map.Malas || - _filter.FilterTokuno && entity.Map != Map.Tokuno || - _filter.FilterTerMur && entity.Map != Map.TerMur || - _filter.FilterInternalMap && entity.Map != Map.Internal || - _filter.FilterNullMap && entity.Map != null) + if (!OnASelectedMap(entity.Map)) { return null; } @@ -208,6 +206,38 @@ public class AdvancedSearchThreadWorker return null; } + // The map boxes are independent checks, so several can be ticked at once: an entity passes when + // its map is any of them. With none ticked there is no map constraint. + private bool OnASelectedMap(Map map) + { + var f = _filter; + + var anySelected = f.FilterFelucca || f.FilterTrammel || f.FilterIlshenar || f.FilterMalas || + f.FilterTokuno || f.FilterTerMur || f.FilterInternalMap || f.FilterNullMap; + + if (!anySelected) + { + return true; + } + + if (map == null) + { + return f.FilterNullMap; + } + + if (map == Map.Internal) + { + return f.FilterInternalMap; + } + + return map == Map.Felucca && f.FilterFelucca || + map == Map.Trammel && f.FilterTrammel || + map == Map.Ilshenar && f.FilterIlshenar || + map == Map.Malas && f.FilterMalas || + map == Map.Tokuno && f.FilterTokuno || + map == Map.TerMur && f.FilterTerMur; + } + private static bool IsValidInternal(Item item) { if (item.Parent != null || item.HeldBy != null) @@ -249,8 +279,7 @@ public class AdvancedSearchThreadWorker return null; } - if (_filter.FilterPropertyTest && - (string.IsNullOrWhiteSpace(_filter.PropertyTest) || !EvaluateRecursive(item, _filter.PropertyTest))) + if (_filter.FilterPropertyTest && !PassesPropertyTest(item)) { return null; } @@ -273,8 +302,7 @@ public class AdvancedSearchThreadWorker return null; } - if (_filter.FilterPropertyTest && - (string.IsNullOrWhiteSpace(_filter.PropertyTest) || !EvaluateRecursive(mobile, _filter.PropertyTest))) + if (_filter.FilterPropertyTest && !PassesPropertyTest(mobile)) { return null; } @@ -353,58 +381,13 @@ public class AdvancedSearchThreadWorker } } - private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) => - AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf)); - - private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan expression) + // The test is compiled once per runtime type for the search and memoized; after that each + // entity costs a dictionary lookup and a delegate call. + private bool PassesPropertyTest(IEntity entity) { - expression = expression.Trim(); - if (expression.Length == 0) - { - return false; - } + var test = _filter.PropertyTest; - var negate = false; - if (expression[0] == '~') - { - negate = true; - expression = expression[1..]; - } - - var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex); - if (operatorSpan.Length == 0) - { - return false; - } - - var propertyName = expression[..operatorIndex].Trim(); - var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim(); - - if (valuePart.Length == 0) - { - return false; - } - - var properties = _propCache.GetOrAdd(entity.GetType(), static t => t.GetProperties()); - PropertyInfo property = null; - for (var i = 0; i < properties.Length; ++i) - { - var p = properties[i]; - if (p.CanRead && p.Name.InsensitiveEquals(propertyName)) - { - property = p; - break; - } - } - - if (property == null) - { - return false; - } - - var propertyValue = property.GetValue(entity); - var result = AdvancedSearchUtilities.CompareValues(property.PropertyType, propertyValue, valuePart, operatorSpan); - - return negate ? !result : result; + return !string.IsNullOrWhiteSpace(test) && + AdvancedSearchConditions.GetPredicate(_predicates, entity.GetType(), test)(entity); } } diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 6775dcaef..f84cef41c 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -1,8 +1,5 @@ using System; using System.Buffers; -using System.Globalization; -using System.Numerics; -using System.Runtime.CompilerServices; namespace Server.Engines.AdvancedSearch; @@ -36,139 +33,6 @@ public static class AdvancedSearchUtilities return expression.Slice(index, 1); } - public static bool CompareValues(Type propertyType, object propertyValue, ReadOnlySpan valuePart, ReadOnlySpan operatorSpan) - { - // TODO: Add support for implicit conversion types like Serial -> uint - - if (propertyType == typeof(long)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(ulong)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(int)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(uint)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(short)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(ushort)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(sbyte)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(byte)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(float)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); - } - if (propertyType == typeof(double)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); - } - if (propertyType == typeof(string)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((string)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(TimeSpan)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(DateTime)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((DateTime)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(bool)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((bool)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType.IsEnum) - { - if (!Enum.TryParse(propertyType, valuePart.ToString(), true, out var valueEnum) || valueEnum == null) - { - return false; - } - - return GetEnumSize(propertyType) switch - { - 1 => CompareNumeric((byte)propertyValue!, (byte)valueEnum, operatorSpan), - 2 => CompareNumeric((short)propertyValue!, (short)valueEnum, operatorSpan), - 4 => CompareNumeric((int)propertyValue!, (int)valueEnum, operatorSpan), - 8 => CompareNumeric((long)propertyValue!, (long)valueEnum, operatorSpan), - _ => false - }; - } - // Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity - // properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy - // RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared, - // thread-safe Types converter so the target is parsed into the property's real type, then - // compare by value. A string is allocated here, but this is the uncommon path; the common - // types never reach it. Types returns a non-null message when it can't parse -> no match. - return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null && - CompareReference(propertyValue!, parsed, operatorSpan); - } - - public static bool CompareNumeric(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - ">" => propertyValue > parsedValue, - "<" => propertyValue < parsedValue, - ">=" => propertyValue >= parsedValue, - "<=" => propertyValue <= parsedValue, - _ => false - }; - - public static bool Compare( - double propertyValue, - double parsedValue, - ReadOnlySpan originalValue, - ReadOnlySpan operatorSpan - ) - { - var epsilon = CalculateEpsilon(originalValue); - - return operatorSpan switch - { - "=" or "==" => Math.Abs(propertyValue - parsedValue) < epsilon, - "!" or "!=" => Math.Abs(propertyValue - parsedValue) >= epsilon, - ">" => propertyValue > parsedValue + epsilon, - "<" => propertyValue < parsedValue - epsilon, - ">=" => propertyValue >= parsedValue - epsilon, - "<=" => propertyValue <= parsedValue + epsilon, - _ => throw new ArgumentException("Invalid operator") - }; - } - public static double CalculateEpsilon(ReadOnlySpan value) { var decimalPlace = value.IndexOf('.'); @@ -191,247 +55,4 @@ public static class AdvancedSearchUtilities _ => 1E-16 }; } - - public static bool Compare(string propertyValue, string parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue.EqualsOrdinal(parsedValue), - "!" or "!=" => !propertyValue.EqualsOrdinal(parsedValue), - ">" => propertyValue.StartsWithOrdinal(parsedValue), - "<" => propertyValue.EndsWithOrdinal(parsedValue), - "~" => propertyValue.Contains(parsedValue), - "~<" => propertyValue.InsensitiveEndsWith(parsedValue), - "~>" => propertyValue.InsensitiveStartsWith(parsedValue), - "~~" => propertyValue.InsensitiveContains(parsedValue), - "~=" => propertyValue.InsensitiveEquals(parsedValue), - "~!" => !propertyValue.InsensitiveEquals(parsedValue), - _ => false - }; - - public static bool Compare(TimeSpan propertyValue, TimeSpan parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - ">" => propertyValue > parsedValue, - "<" => propertyValue < parsedValue, - ">=" => propertyValue >= parsedValue, - "<=" => propertyValue <= parsedValue, - _ => false - }; - - public static bool Compare(DateTime propertyValue, DateTime parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - ">" => propertyValue > parsedValue, - "<" => propertyValue < parsedValue, - ">=" => propertyValue >= parsedValue, - "<=" => propertyValue <= parsedValue, - _ => false - }; - - public static bool Compare(bool propertyValue, bool parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - _ => false - }; - - public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) - { - switch (operatorSpan) - { - case "=": - case "==": return Equals(propertyValue, parsedValue); - case "!": - case "!=": return !Equals(propertyValue, parsedValue); - } - - if (propertyValue is IComparable cmp && parsedValue != null) - { - try - { - var c = cmp.CompareTo(parsedValue); - return operatorSpan switch - { - ">" => c > 0, - "<" => c < 0, - ">=" => c >= 0, - "<=" => c <= 0, - _ => false - }; - } - catch - { - return false; - } - } - - return false; - } - - internal static bool TryParseValue(ReadOnlySpan valuePart, out T value) - { - // Special handling for boolean and hexadecimal values - if (typeof(T) == typeof(bool)) - { - var val = valuePart.ToString().ToLower(); - if (val is "true" or "1" or "enabled" or "on") - { - value = (T)(object)true; - return true; - } - - if (val is "false" or "0" or "disabled" or "off") - { - value = (T)(object)false; - return true; - } - - value = default; - return false; - } - - if (typeof(T) == typeof(long)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(ulong)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(int)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(uint)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(short)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(ushort)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(sbyte)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(byte)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(float)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(double)) - { - return TryParseNumericValue(valuePart, out value); - } - - // string needs no parsing — the span itself is the value. - if (typeof(T) == typeof(string)) - { - value = (T)(object)valuePart.ToString(); - return true; - } - - // Remaining supported types (TimeSpan, DateTime) parse straight from the span via - // ISpanParsable — no allocation, no reflection, and unlike Convert.ChangeType it handles - // TimeSpan, which is not IConvertible and previously failed silently. - if (typeof(T) == typeof(TimeSpan)) - { - return TryParseSpanParsable(valuePart, out value); - } - - if (typeof(T) == typeof(DateTime)) - { - return TryParseSpanParsable(valuePart, out value); - } - - value = default; - return false; - } - - // Parses U (a value type exposing ISpanParsable) from the span and reinterprets it as T. The - // two type params mirror TryParseNumericValue: the caller dispatches on typeof(T), so U == T at - // every call site and the (T)(object) cast is always valid. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryParseSpanParsable(ReadOnlySpan valuePart, out T value) where U : ISpanParsable - { - if (U.TryParse(valuePart, null, out var parsed)) - { - value = (T)(object)parsed; - return true; - } - - value = default; - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryParseNumericValue(ReadOnlySpan valuePart, out R value) where T : INumber - { - var ok = valuePart.StartsWith("0x") - ? T.TryParse(valuePart[2..], NumberStyles.HexNumber, null, out var parsed) - : T.TryParse(valuePart, null, out parsed); - - if (ok) - { - value = (R)(object)parsed; - return true; - } - - value = default; - return false; - } - - // Evaluates one trimmed leaf atom against caller-supplied state. A custom delegate is required - // because ReadOnlySpan cannot be a Func<> type argument; passing state avoids a per-call - // capturing closure, so the recursion allocates neither a string nor a closure. - internal delegate bool LeafEvaluator(TState state, ReadOnlySpan leaf); - - // OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND. - internal static bool EvaluateBoolean(ReadOnlySpan expr, TState state, LeafEvaluator evalLeaf) - { - var orIndex = expr.IndexOf('|'); - if (orIndex != -1) - { - return EvaluateBoolean(expr[..orIndex], state, evalLeaf) || EvaluateBoolean(expr[(orIndex + 1)..], state, evalLeaf); - } - - var andIndex = expr.IndexOf('@'); - if (andIndex != -1) - { - return EvaluateBoolean(expr[..andIndex], state, evalLeaf) && EvaluateBoolean(expr[(andIndex + 1)..], state, evalLeaf); - } - - return evalLeaf(state, expr.Trim()); - } - - private static int GetEnumSize(Type enumType) => - Type.GetTypeCode(Enum.GetUnderlyingType(enumType)) switch - { - TypeCode.Byte or TypeCode.SByte => sizeof(byte), - TypeCode.Int16 or TypeCode.UInt16 => sizeof(ushort), - TypeCode.Int32 or TypeCode.UInt32 => sizeof(uint), - TypeCode.Int64 or TypeCode.UInt64 => sizeof(ulong), - _ => 4 - }; } diff --git a/Projects/UOContent/Misc/Emitter.cs b/Projects/UOContent/Misc/Emitter.cs deleted file mode 100644 index 61ba4cb7f..000000000 --- a/Projects/UOContent/Misc/Emitter.cs +++ /dev/null @@ -1,727 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; - -namespace Server -{ - public class AssemblyEmitter - { - private readonly ModuleBuilder m_ModuleBuilder; - - public AssemblyEmitter(string assemblyName) - { - var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName(assemblyName), - AssemblyBuilderAccess.Run - ); - - m_ModuleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName); - } - - public TypeBuilder DefineType(string typeName, TypeAttributes attrs, Type parentType) => - m_ModuleBuilder.DefineType(typeName, attrs, parentType); - } - - public class MethodEmitter - { - public delegate void Callback(); - - private readonly Stack m_Calls; - - private readonly Stack m_Stack; - - private readonly Dictionary> m_Temps; - private Type[] m_ArgumentTypes; - - public MethodEmitter(TypeBuilder typeBuilder) - { - Type = typeBuilder; - - m_Temps = new Dictionary>(); - - m_Stack = new Stack(); - m_Calls = new Stack(); - } - - public TypeBuilder Type { get; } - - public ILGenerator Generator { get; private set; } - - public MethodBuilder Method { get; private set; } - - public Type Active => m_Stack.Peek(); - - public void Define(string name, MethodAttributes attr, Type returnType, Type[] parms) - { - Method = Type.DefineMethod(name, attr, returnType, parms); - Generator = Method.GetILGenerator(); - - m_ArgumentTypes = parms; - } - - public LocalBuilder CreateLocal(Type localType) => Generator.DeclareLocal(localType); - - public LocalBuilder AcquireTemp(Type localType) - { - if (!m_Temps.TryGetValue(localType, out var list)) - { - m_Temps[localType] = list = new Queue(); - } - - return list.Count > 0 ? list.Dequeue() : CreateLocal(localType); - } - - public void ReleaseTemp(LocalBuilder local) - { - if (local.LocalType == null) - { - return; - } - - if (!m_Temps.TryGetValue(local.LocalType, out var list)) - { - m_Temps[local.LocalType] = list = new Queue(); - } - - list.Enqueue(local); - } - - public void Branch(Label label) - { - Generator.Emit(OpCodes.Br, label); - } - - public void BranchIfFalse(Label label) - { - Pop(typeof(object)); - - Generator.Emit(OpCodes.Brfalse, label); - } - - public void BranchIfTrue(Label label) - { - Pop(typeof(object)); - - Generator.Emit(OpCodes.Brtrue, label); - } - - public Label CreateLabel() => Generator.DefineLabel(); - - public void MarkLabel(Label label) - { - Generator.MarkLabel(label); - } - - public void Pop() - { - m_Stack.Pop(); - } - - public void Pop(Type expected) - { - if (expected == null) - { - throw new InvalidOperationException("Expected type cannot be null."); - } - - var onStack = m_Stack.Pop(); - - if (expected == typeof(bool)) - { - expected = typeof(int); - } - - if (onStack == typeof(bool)) - { - onStack = typeof(int); - } - - if (!expected.IsAssignableFrom(onStack)) - { - throw new InvalidOperationException("Unexpected stack state."); - } - } - - public void Push(Type type) - { - m_Stack.Push(type); - } - - public void Return() - { - if (m_Stack.Count != (Method.ReturnType == typeof(void) ? 0 : 1)) - { - throw new InvalidOperationException("Stack return mismatch."); - } - - Generator.Emit(OpCodes.Ret); - } - - public void LoadNull() - { - LoadNull(typeof(object)); - } - - public void LoadNull(Type type) - { - Push(type); - - Generator.Emit(OpCodes.Ldnull); - } - - public void Load(string value) - { - Push(typeof(string)); - - if (value != null) - { - Generator.Emit(OpCodes.Ldstr, value); - } - else - { - Generator.Emit(OpCodes.Ldnull); - } - } - - public void Load(Enum value) - { - var toLoad = ((IConvertible)value).ToInt32(null); - Load(toLoad); - - Pop(); - Push(value.GetType()); - } - - public void Load(long value) - { - Push(typeof(long)); - - Generator.Emit(OpCodes.Ldc_I8, value); - } - - public void Load(float value) - { - Push(typeof(float)); - - Generator.Emit(OpCodes.Ldc_R4, value); - } - - public void Load(double value) - { - Push(typeof(double)); - - Generator.Emit(OpCodes.Ldc_R8, value); - } - - public void Load(char value) - { - Load((int)value); - - Pop(); - Push(typeof(char)); - } - - public void Load(bool value) - { - Push(typeof(bool)); - - if (value) - { - Generator.Emit(OpCodes.Ldc_I4_1); - } - else - { - Generator.Emit(OpCodes.Ldc_I4_0); - } - } - - public void Load(int value) - { - Push(typeof(int)); - - switch (value) - { - case -1: - Generator.Emit(OpCodes.Ldc_I4_M1); - break; - - case 0: - Generator.Emit(OpCodes.Ldc_I4_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldc_I4_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldc_I4_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldc_I4_3); - break; - - case 4: - Generator.Emit(OpCodes.Ldc_I4_4); - break; - - case 5: - Generator.Emit(OpCodes.Ldc_I4_5); - break; - - case 6: - Generator.Emit(OpCodes.Ldc_I4_6); - break; - - case 7: - Generator.Emit(OpCodes.Ldc_I4_7); - break; - - case 8: - Generator.Emit(OpCodes.Ldc_I4_8); - break; - - default: - if (value >= sbyte.MinValue && value <= sbyte.MaxValue) - { - Generator.Emit(OpCodes.Ldc_I4_S, (sbyte)value); - } - else - { - Generator.Emit(OpCodes.Ldc_I4, value); - } - - break; - } - } - - public void LoadField(FieldInfo field) - { - Pop(field.DeclaringType); - - Push(field.FieldType); - - Generator.Emit(OpCodes.Ldfld, field); - } - - public void LoadLocal(LocalBuilder local) - { - Push(local.LocalType); - - var index = local.LocalIndex; - - switch (index) - { - case 0: - Generator.Emit(OpCodes.Ldloc_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldloc_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldloc_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldloc_3); - break; - - default: - if (index >= byte.MinValue && index <= byte.MinValue) - { - Generator.Emit(OpCodes.Ldloc_S, (byte)index); - } - else - { - Generator.Emit(OpCodes.Ldloc, (short)index); - } - - break; - } - } - - public void StoreLocal(LocalBuilder local) - { - Pop(local.LocalType); - - Generator.Emit(OpCodes.Stloc, local); - } - - public void LoadArgument(int index) - { - if (index > 0) - { - Push(m_ArgumentTypes[index - 1]); - } - else - { - Push(Type); - } - - switch (index) - { - case 0: - Generator.Emit(OpCodes.Ldarg_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldarg_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldarg_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldarg_3); - break; - - default: - if (index >= byte.MinValue && index <= byte.MaxValue) - { - Generator.Emit(OpCodes.Ldarg_S, (byte)index); - } - else - { - Generator.Emit(OpCodes.Ldarg, (short)index); - } - - break; - } - } - - public void CastAs(Type type) - { - Pop(typeof(object)); - Push(type); - - Generator.Emit(OpCodes.Isinst, type); - } - - public void Neg() - { - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Neg); - } - - public void Compare(OpCode opCode) - { - Pop(); - Pop(); - - Push(typeof(int)); - - Generator.Emit(opCode); - } - - public void LogicalNot() - { - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Ldc_I4_0); - Generator.Emit(OpCodes.Ceq); - } - - public void Xor() - { - Pop(typeof(int)); - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Xor); - } - - public void Chain(Property prop) - { - for (var i = 0; i < prop.Chain.Length; ++i) - { - Call(prop.Chain[i].GetGetMethod()); - } - } - - public void Call(MethodInfo method) - { - BeginCall(method); - - var call = m_Calls.Peek(); - - if (call.parms.Length > 0) - { - throw new InvalidOperationException("Method requires parameters."); - } - - FinishCall(); - } - - public bool CompareTo(int sign, Callback argGenerator) - { - var active = Active; - - var compareTo = active.GetMethod("CompareTo", new[] { active }); - - if (compareTo == null) - { - /* This gets a little tricky... - * - * There's a scenario where we might be trying to use CompareTo on an interface - * which, while it doesn't explicitly implement CompareTo itself, is said to - * extend IComparable indirectly. The implementation is implicitly passed off - * to implementers... - * - * interface ISomeInterface : IComparable - * { - * void SomeMethod(); - * } - * - * class SomeClass : ISomeInterface - * { - * void SomeMethod() { ... } - * int CompareTo( object other ) { ... } - * } - * - * In this case, calling ISomeInterface.GetMethod( "CompareTo" ) will return null. - * - * Bleh. - */ - - var ifaces = active.FindInterfaces( - (type, obj) => type.IsGenericType - && type.GetGenericTypeDefinition() == typeof(IComparable<>) - && type.GetGenericArguments()[0].IsAssignableFrom(active), - null - ); - - if (ifaces.Length > 0) - { - compareTo = ifaces[0].GetMethod("CompareTo", new[] { active }); - } - else - { - ifaces = active.FindInterfaces((type, obj) => type == typeof(IComparable), null); - - if (ifaces.Length > 0) - { - compareTo = ifaces[0].GetMethod("CompareTo", new[] { active }); - } - } - } - - if (compareTo == null) - { - return false; - } - - if (!active.IsValueType) - { - /* This object is a reference type, so we have to make it behave - * - * null.CompareTo( null ) = 0 - * real.CompareTo( null ) = -1 - * null.CompareTo( real ) = +1 - * - */ - - var aValue = AcquireTemp(active); - var bValue = AcquireTemp(active); - - StoreLocal(aValue); - - argGenerator(); - - StoreLocal(bValue); - - /* if (aValue == null) - * { - * if (bValue == null) - * v = 0; - * else - * v = +1; - * } - * else if (bValue == null) - * { - * v = -1; - * } - * else - * { - * v = aValue.CompareTo( bValue ); - * } - */ - - var store = CreateLabel(); - - var aNotNull = CreateLabel(); - - LoadLocal(aValue); - BranchIfTrue(aNotNull); - // if (aValue == null) - { - var bNotNull = CreateLabel(); - - LoadLocal(bValue); - BranchIfTrue(bNotNull); - // if (bValue == null) - { - Load(0); - Pop(typeof(int)); - Branch(store); - } - MarkLabel(bNotNull); - // else - { - Load(sign); - Pop(typeof(int)); - Branch(store); - } - } - MarkLabel(aNotNull); - // else - { - var bNotNull = CreateLabel(); - - LoadLocal(bValue); - BranchIfTrue(bNotNull); - // bValue == null - { - Load(-sign); - Pop(typeof(int)); - Branch(store); - } - MarkLabel(bNotNull); - // else - { - LoadLocal(aValue); - BeginCall(compareTo); - - LoadLocal(bValue); - ArgumentPushed(); - - FinishCall(); - - if (sign == -1) - { - Neg(); - } - } - } - - MarkLabel(store); - - ReleaseTemp(aValue); - ReleaseTemp(bValue); - } - else - { - BeginCall(compareTo); - - argGenerator(); - - ArgumentPushed(); - - FinishCall(); - - if (sign == -1) - { - Neg(); - } - } - - return true; - } - - public void BeginCall(MethodInfo method) - { - var type = (method.CallingConvention & CallingConventions.HasThis) != 0 ? m_Stack.Peek() : method.DeclaringType; - - m_Calls.Push(new CallInfo(type, method)); - - if (type!.IsValueType) - { - var temp = AcquireTemp(type); - - Generator.Emit(OpCodes.Stloc, temp); - Generator.Emit(OpCodes.Ldloca, temp); - - ReleaseTemp(temp); - } - } - - public void FinishCall() - { - var call = m_Calls.Pop(); - - if ((call.type.IsValueType || call.type.IsByRef) && call.method.DeclaringType != call.type) - { - Generator.Emit(OpCodes.Constrained, call.type); - } - - if (call.method.DeclaringType?.IsValueType == true || call.method.IsStatic) - { - Generator.Emit(OpCodes.Call, call.method); - } - else - { - Generator.Emit(OpCodes.Callvirt, call.method); - } - - for (var i = call.parms.Length - 1; i >= 0; --i) - { - Pop(call.parms[i].ParameterType); - } - - if ((call.method.CallingConvention & CallingConventions.HasThis) != 0) - { - Pop(call.method.DeclaringType); - } - - if (call.method.ReturnType != typeof(void)) - { - Push(call.method.ReturnType); - } - } - - public void ArgumentPushed() - { - var call = m_Calls.Peek(); - - var parm = call.parms[call.index++]; - - var argumentType = m_Stack.Peek(); - - if (!parm.ParameterType.IsAssignableFrom(argumentType)) - { - throw new InvalidOperationException("Parameter type mismatch."); - } - - if (argumentType.IsValueType && !parm.ParameterType.IsValueType) - { - Generator.Emit(OpCodes.Box, argumentType); - } - } - - private class CallInfo - { - public readonly MethodInfo method; - public readonly ParameterInfo[] parms; - public readonly Type type; - - public int index; - - public CallInfo(Type type, MethodInfo method) - { - this.type = type; - this.method = method; - - parms = method.GetParameters(); - } - } - } -} From f7caff8ad3f7dabaab1d76884db8396e06a79755 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:14:09 -0700 Subject: [PATCH 12/21] fix: Advanced Search results came back in a different order every search (#2626) ## Summary Advanced Search results came back in a different order every search, whether or not a sort was chosen. Follow-up to #2625, where it was noticed while testing the property test. ## Why Results arrive from the search workers in whatever order they finished. Each sort the gump offers compares a single key and `Array.Sort` is not stable, so equal keys, which is most rows under type or map, landed in a different order every run; with no sort chosen the list was arrival order outright. The range sort also answered 0 for any two results off the viewer's map, so those shuffled as well. ## What changed - The five comparers share one base class whose `Compare` applies the chosen key and then a fixed tie-break, serial ascending regardless of direction. The direction is about the key; a fixed order among equal keys is what keeps two searches identical. - The collected result list is put in serial order before the gump sees it, so an unsorted search is deterministic too. That sort runs on the thread-pool work item that already assembles the list and reads only the result records. - No change to which key each sort uses or to what ascending and descending mean. ## Test plan - [x] New `AdvancedSearchResultOrderTests`: six equal-keyed results offered in two arrival orders to every comparer in both directions, asserting the same sequence and serial order; the range comparer on off-map results; reverse flips the key but not the tie-break. - [x] `UOContent.Tests`: 873 passed, 0 failed - [x] `Server.Tests`: 869 passed, 0 failed - [ ] In game: run the same search twice with each sort and with none; the lists match. --- .../AdvancedSearchResultOrderTests.cs | 132 +++++++++++++ .../Advanced Search/AdvancedSearchGump.cs | 2 + .../AdvancedSearchResultComparers.cs | 185 ++++++++++-------- 3 files changed, 240 insertions(+), 79 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs new file mode 100644 index 000000000..f6921de9e --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Engines.AdvancedSearch; +using Server.Items; +using Xunit; + +namespace UOContent.Tests; + +// Every sort must give the same sequence from any arrival order: key first, then serial. +[Collection("Sequential UOContent Tests")] +public class AdvancedSearchResultOrderTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private AdvancedSearchResult Result(Item item, string name = "same", Map map = null) + { + _items.Add(item); + return new AdvancedSearchResult(name, item.GetType(), item.Location, map ?? Map.Felucca, null) { Entity = item }; + } + + private AdvancedSearchResult[] EqualKeyed() + { + var results = new AdvancedSearchResult[6]; + + for (var i = 0; i < results.Length; i++) + { + results[i] = Result(new Item(0x1)); + } + + return results; + } + + private static AdvancedSearchResult[] Shuffled(AdvancedSearchResult[] inOrder, params int[] permutation) + { + var shuffled = new AdvancedSearchResult[inOrder.Length]; + + for (var i = 0; i < permutation.Length; i++) + { + shuffled[i] = inOrder[permutation[i]]; + } + + return shuffled; + } + + private static void AssertSameSequenceFromAnyArrival( + AdvancedSearchResult[] inOrder, + IComparer comparer + ) + { + var first = Shuffled(inOrder, 3, 0, 5, 1, 4, 2); + var second = Shuffled(inOrder, 5, 4, 3, 2, 1, 0); + + Array.Sort(first, comparer); + Array.Sort(second, comparer); + + Assert.Equal(first, second); + } + + public static IEnumerable Comparers() + { + yield return [AdvancedSearchResultSerialComparer.Instance]; + yield return [AdvancedSearchResultTypeComparer.Instance]; + yield return [AdvancedSearchResultTypeComparer.InstanceReverse]; + yield return [AdvancedSearchResultNameComparer.Instance]; + yield return [AdvancedSearchResultNameComparer.InstanceReverse]; + yield return [AdvancedSearchResultMapComparer.Instance]; + yield return [AdvancedSearchResultMapComparer.InstanceReverse]; + yield return [AdvancedSearchResultSelectedComparer.Instance]; + yield return [AdvancedSearchResultSelectedComparer.InstanceReverse]; + } + + [Theory] + [MemberData(nameof(Comparers))] + public void EqualKeysOrderBySerialFromAnyArrivalOrder(IComparer comparer) + { + var inOrder = EqualKeyed(); + + AssertSameSequenceFromAnyArrival(inOrder, comparer); + + var sorted = Shuffled(inOrder, 3, 0, 5, 1, 4, 2); + Array.Sort(sorted, comparer); + + Assert.Equal(inOrder, sorted); + } + + // Off-map results compare equal on range. + [Fact] + public void RangeComparerOrdersOffMapResultsBySerial() + { + var from = new Mobile(); + from.MoveToWorld(new Point3D(1000, 1000, 0), Map.Trammel); + + try + { + var inOrder = EqualKeyed(); + + AssertSameSequenceFromAnyArrival(inOrder, new AdvancedSearchRangeComparer(from)); + AssertSameSequenceFromAnyArrival(inOrder, new AdvancedSearchRangeComparer(from, true)); + } + finally + { + from.Delete(); + } + } + + [Fact] + public void ReverseFlipsTheKeyNotTheTieBreak() + { + var a1 = Result(new Item(0x1), "alpha"); + var a2 = Result(new Item(0x1), "alpha"); + var b1 = Result(new Item(0x1), "beta"); + + var results = new[] { b1, a2, a1 }; + + Array.Sort(results, AdvancedSearchResultNameComparer.Instance); + Assert.Equal([a1, a2, b1], results); + + Array.Sort(results, AdvancedSearchResultNameComparer.InstanceReverse); + Assert.Equal([b1, a1, a2], results); + } +} diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index 96c357fd0..e788128f2 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -819,6 +819,8 @@ public class AdvancedSearchGump : Gump } } + resultsList.Sort(AdvancedSearchResultSerialComparer.Instance); + SearchResults = resultsList.ToArray(); // Force the GC to collect the results diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs index 8842b8f80..1a455064d 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs @@ -2,86 +2,118 @@ using System.Collections.Generic; namespace Server.Engines.AdvancedSearch; -public class AdvancedSearchResultTypeComparer : IComparer +// Results arrive in worker-finish order, so every sort ends in the same serial tie-break. +public abstract class AdvancedSearchResultComparer : IComparer { - public static readonly AdvancedSearchResultTypeComparer Instance = new(); - public static readonly AdvancedSearchResultTypeComparer InstanceReverse = new(true); + protected readonly bool Reverse; - private readonly bool _reverse; - - public AdvancedSearchResultTypeComparer(bool reverse = false) => _reverse = reverse; + protected AdvancedSearchResultComparer(bool reverse) => Reverse = reverse; public int Compare(AdvancedSearchResult x, AdvancedSearchResult y) { - var a = x?.Entity?.GetType().Name; - var b = y?.Entity?.GetType().Name; - - return _reverse ? b.InsensitiveCompare(a) : a.InsensitiveCompare(b); - } -} - -public class AdvancedSearchResultNameComparer : IComparer -{ - public static readonly AdvancedSearchResultNameComparer Instance = new(); - public static readonly AdvancedSearchResultNameComparer InstanceReverse = new(true); - - private readonly bool _reverse; - - public AdvancedSearchResultNameComparer(bool reverse = false) => _reverse = reverse; - - public int Compare(AdvancedSearchResult x, AdvancedSearchResult y) - { - var a = x?.Name; - var b = y?.Name; - - return _reverse ? b.InsensitiveCompare(a) : a.InsensitiveCompare(b); - } -} - -public class AdvancedSearchResultMapComparer : IComparer -{ - public static readonly AdvancedSearchResultMapComparer Instance = new(); - public static readonly AdvancedSearchResultMapComparer InstanceReverse = new(true); - - private readonly bool _reverse; - - public AdvancedSearchResultMapComparer(bool reverse = false) => _reverse = reverse; - - public int Compare(AdvancedSearchResult x, AdvancedSearchResult y) - { - var a = x?.Map?.MapID ?? -1; - var b = y?.Map?.MapID ?? -1; - - return _reverse ? b.CompareTo(a) : a.CompareTo(b); - } -} - -public class AdvancedSearchRangeComparer : IComparer -{ - private readonly bool _reverse; - private readonly Mobile _from; - - public AdvancedSearchRangeComparer(Mobile from, bool reverse = false) - { - _from = from; - _reverse = reverse; - } - - public int Compare(AdvancedSearchResult x, AdvancedSearchResult y) - { - if (_from == null || x == null && y == null) + if (ReferenceEquals(x, y)) { return 0; } if (x == null) { - return _reverse ? 1 : -1; + return -1; } if (y == null) { - return _reverse ? -1 : 1; + return 1; + } + + var c = CompareKey(x, y); + + return c != 0 ? c : CompareSerial(x, y); + } + + protected abstract int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y); + + // Always ascending; the direction applies to the key only. + public static int CompareSerial(AdvancedSearchResult x, AdvancedSearchResult y) + { + var a = x.Entity?.Serial ?? Serial.Zero; + var b = y.Entity?.Serial ?? Serial.Zero; + + return a.CompareTo(b); + } +} + +public sealed class AdvancedSearchResultSerialComparer : AdvancedSearchResultComparer +{ + public static readonly AdvancedSearchResultSerialComparer Instance = new(); + + private AdvancedSearchResultSerialComparer() : base(false) + { + } + + protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) => 0; +} + +public sealed class AdvancedSearchResultTypeComparer : AdvancedSearchResultComparer +{ + public static readonly AdvancedSearchResultTypeComparer Instance = new(); + public static readonly AdvancedSearchResultTypeComparer InstanceReverse = new(true); + + public AdvancedSearchResultTypeComparer(bool reverse = false) : base(reverse) + { + } + + protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) + { + var a = x.Entity?.GetType().Name; + var b = y.Entity?.GetType().Name; + + return Reverse ? b.InsensitiveCompare(a) : a.InsensitiveCompare(b); + } +} + +public sealed class AdvancedSearchResultNameComparer : AdvancedSearchResultComparer +{ + public static readonly AdvancedSearchResultNameComparer Instance = new(); + public static readonly AdvancedSearchResultNameComparer InstanceReverse = new(true); + + public AdvancedSearchResultNameComparer(bool reverse = false) : base(reverse) + { + } + + protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) => + Reverse ? y.Name.InsensitiveCompare(x.Name) : x.Name.InsensitiveCompare(y.Name); +} + +public sealed class AdvancedSearchResultMapComparer : AdvancedSearchResultComparer +{ + public static readonly AdvancedSearchResultMapComparer Instance = new(); + public static readonly AdvancedSearchResultMapComparer InstanceReverse = new(true); + + public AdvancedSearchResultMapComparer(bool reverse = false) : base(reverse) + { + } + + protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) + { + var a = x.Map?.MapID ?? -1; + var b = y.Map?.MapID ?? -1; + + return Reverse ? b.CompareTo(a) : a.CompareTo(b); + } +} + +public sealed class AdvancedSearchRangeComparer : AdvancedSearchResultComparer +{ + private readonly Mobile _from; + + public AdvancedSearchRangeComparer(Mobile from, bool reverse = false) : base(reverse) => _from = from; + + protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) + { + if (_from == null) + { + return 0; } var fromMap = _from.Map; @@ -93,36 +125,31 @@ public class AdvancedSearchRangeComparer : IComparer if (x.Map == fromMap && y.Map != fromMap) { - return _reverse ? 1 : -1; + return Reverse ? 1 : -1; } if (x.Map != fromMap && y.Map == fromMap) { - return _reverse ? -1 : 1; + return Reverse ? -1 : 1; } var xDist = _from.GetDistanceToSqrt(x.Location); var yDist = _from.GetDistanceToSqrt(y.Location); - return _reverse ? yDist.CompareTo(xDist) : xDist.CompareTo(yDist); + return Reverse ? yDist.CompareTo(xDist) : xDist.CompareTo(yDist); } } -public class AdvancedSearchResultSelectedComparer : IComparer +public sealed class AdvancedSearchResultSelectedComparer : AdvancedSearchResultComparer { public static readonly AdvancedSearchResultSelectedComparer Instance = new(); public static readonly AdvancedSearchResultSelectedComparer InstanceReverse = new(true); - private readonly bool _reverse; - - public AdvancedSearchResultSelectedComparer(bool reverse = false) => _reverse = reverse; - - public int Compare(AdvancedSearchResult x, AdvancedSearchResult y) + public AdvancedSearchResultSelectedComparer(bool reverse = false) : base(reverse) { - var a = x?.Selected ?? false; - var b = y?.Selected ?? false; - - // True then false, which is 1 then 0, so the comparison is reverse of integers - return _reverse ? a.CompareTo(b) : b.CompareTo(a); } + + // True then false, which is 1 then 0, so the comparison is reverse of integers + protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) => + Reverse ? x.Selected.CompareTo(y.Selected) : y.Selected.CompareTo(x.Selected); } From f606225f476d8b91e247e8b939a66770bd468d04 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:28:13 -0700 Subject: [PATCH 13/21] fix: pets freeze, forget their orders, obey the wrong players, and fight when told not to (#2614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pet orders lived in two files with nothing enforcing which phase owned what: `PetOrderHandlers.cs` ran one-shot handlers inside the `ControlOrder` setter and `PetOrders.cs` ran `DoOrderXxx` every AI tick from `Obey`. Friend/Unfriend refusals repeated their message every tick, Rename froze the pet, Drop on a dead pet never ended, the loyalty drain bypassed the release handler, and the command issuer leaked through a public field that only some handlers cleared (#2613 fixed the Release casualty of that split; this finishes the job). Every order now lives in one place, `PetOrders.cs`, with two named phases: - **Issue** — `BaseAI.IssueOrder(order, previous, issuer, resuming, interruptedTarget)` runs once, synchronously, from the new `BaseCreature.SetControlOrder` funnel. It may only set state and emit (message, sound, reveal) and returns the order to rest in. The funnel loops to a fixed point, so transient orders (Drop, Friend, Unfriend, Transfer, Release, Rename, Stop, Patrol) resolve before the setter returns and can never rest. - **Tick** — `DoOrderXxx` runs from `Obey` for the six restable orders only (None, Come, Guard, Attack, Stay, Follow). Anything else that arrives there came from an old save and falls back to the standing order. The issuer is a parameter: `BaseCreature.IssueOrder(order, issuer, target)` is the entry for player commands (speech, context menu, targeting), a raw `ControlOrder = x` assignment is a system-issued order, and nothing has to remember to clear anything. A resumed Follow restores the mobile the standing Follow was following, never a transient's target. Because the funnel is synchronous it also carries the order being interrupted, so an administrative command can hand control back to what the pet was doing without storing anything per creature. ## Era behaviour, with sources Two publishes govern most of the questions here, and the inherited code matched neither exactly. [**Publish 16**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-4-23rd-july/) (23 July 2002) — *"The 'stop' command will stop a pet from guarding, following, and attacking."* Stop cancels the current attack and leaves the pet idle but still reactive. That is what this branch does whenever the stand-down policy below is off, in every era. (The same publish's *"Friends will only be able to issue movement commands to pets"* is the rule already enforced by `IsFriendOrder`.) [**Publish 51**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2008-2/publish-51-26th-march/) (26 March 2008) lists it per command: > Follow: The pet should follow. It will not attack anything, even if it is attacked. > > Come: The pet should come. It will not attack anything, even if it is attacked. > > Stay: The pet will stay where it is currently, and will not attack anything, even if it is attacked. > > Stop: The pet will stop attacking. It will not attack anything, even if it is attacked, and may wander. > > Guard: The pet should guard as it does currently. > > Kill/Attack: The pet will attack its target as it does currently. The inherited rule covered Follow and Stay only, so a pet told to come fought back, and it could not cover Stop at all: Stop resolves to None rather than resting, and None is exactly the state the publish describes. `BaseAI.IsStandDownOrder` now names the set — Follow, Come, Stay, None — and both halves of `AggressiveAction` read it. ## Configuration `taming.petsStandDownOnCommand` (default `Core.ML`) controls the Publish 51 behaviour. The publish has no step of its own on the expansion ladder — it lands between ML and SA, and Kingdom Reborn was a client rather than an expansion — so it keeps riding ML as before, and the setting carries the rest: the behaviour is popular well outside its era, so a shard on AOS that wants it sets the key, and one that does not clears it. `BaseCreature.StandsDownOnCommand` is virtual for a creature that should differ. ## Bugs fixed along the way - Friend/Unfriend refusal spam (every tick until the next command); Rename freezing the pet; Drop on a dead or non-`CanDrop` pet freezing the pet. - Loyalty-zero release skipping the name clear and the summoned kill; a released pet keeping its `Friends` list and its previous owner's standing order. - A transferred pet still answering to the previous owner's friends; transfer playing the idle sound twice. - `BaseTalisman` summons issued `Friend` with no target (*looks confused* forever, or young-player spam); they follow their owner. - Speech: single-pet commands lost their name gate after #2232 (a bare "come" moved every pet in range; "all stay" issued Stay twice); the speech cases passed a hardcoded `isOwner: true`, so a pet friend could say "`` drop" and dump the pack, or issue Come/Guard. - GM "`` obey" was unreachable for a controlled pet; context-menu Release and speech Release disagreed about the control roll (resolved by removing it from both, below). - Login derived the standing order from proximity even for a pet saved on Stay (zeroing its post), and only recorded the derived order without issuing it, so a pet saved mid-transient idled after a restart. - `Friends` mutations never marked the creature dirty for delta saves. - A resumed standing Follow was left without a target and cancelled itself to idle on the next think — the same defect as #2616, fixed here by `IssueFollow(resuming)` restoring the remembered target. ## Behaviour changes a shard maintainer will notice - Every player command reveals its issuer, including context-menu commands from a hidden owner. This restores the blanket reveal (RunUO reveals in every order arm) minus its bug: it revealed the **control master**, so a friend's command popped the owner wherever they stood. Speech already reveals through `Mobile.OnSaid`, so the practical change is the context menu, target picks and the release gump. - Resumed/chained orders are silent (no idle sound when falling back after Drop, Stop, a refused Friend, etc.). - **Administrative commands no longer call the pet off.** Drop, Friend, Unfriend and Rename keep the pet's combat posture and hand control back to the order they interrupted, target and all; the standing order is the fallback only when the interrupted order cannot resume (a transient, or an attack whose target died, left or hid). Resuming an attack does not repeat its aggression or replay its bark. - **Friend and Unfriend no longer rewrite the standing order.** Previously a success pointed the pet at the new friend and made Follow its standing order, so a pet left on Stay silently became a Follow with its anchor cleared. Friending grants a permission and nothing else; the friend has movement commands and can ask the pet to follow. - **Releasing a pet no longer rolls the control chance**, on either path. A refused roll cost 3 loyalty, and loyalty reaching zero releases the pet anyway, so refusing only converted a deliberate release into an involuntary one minutes later. Both paths gate on `CanBeControlledBy` instead: if you can command it, you can dismiss it. - **The pet distraction roll is gone.** A pet on Follow had a 10% chance per damage callback of dropping the order and attacking whoever hit it, issued without consulting anything, so it overrode the stand-down policy a few hits after the aggression path had correctly ignored it. Its era gate was guesswork by its own comment's admission and no publish describes it; `CanBeDistracted`, `CheckDistracted`, both call sites and the `Golem` override are deleted. Pre-ML shards lose the mechanic. - **A pet's follow pace moved off the think clock.** The AOS sprint wrote a bespoke `CurrentSpeed = 0.1`, which fused both clocks — discarding any configured `ActiveMoveSpeed`/`PassiveMoveSpeed` — and pinned a following pet's AI at 10 Hz even while standing still. `BaseCreature.FollowMoveSpeed` (virtual, AOS 0.1) now caps the resolved step delay while the pet is closing on its master, the same way herding does: nothing stored, and a creature configured faster keeps its own pace. - Transfer with an invalid target is a refusal (resumes) instead of forcing Stay; the transfer combat gate rests on the aggressor lists and `NextCombatTime`. - Stop with no standing order anchors the idle where the pet stands (a vendor-bought pet no longer wanders off unbounded). - The old "master must be alive" bails in the handlers are gone; stand-down and sounds run for orphaned pets too. RunUO gates only on the master being null or deleted, and a living friend commanding a dead owner's pet could not previously call it out of a fight. - Login: a pet at None near its master is issued Follow silently; a saved Stay/Follow/Guard is adopted as is. - GM "all obey" only reaches wild creatures; a controlled pet must be named. - Death still issues Follow with the idle sound, as RunUO did. ## Tests `PetOrderTests` grew from 16 to 63, plus 13 in a new `PetRetaliationTests` for the Publish 51 matrix and 13 in `PetPacingTests` for the clocks. Together they cover order resolution, reveal on every entry path, release parity (player vs drain, summoned), transfer/friend refusals and successes, stand-down and war-mode invariants, the interrupted-order resume, speech gating and permissions, GM obey, login derivation, the load probe, and the retaliation matrix across eras and both damage callbacks. Whole project green: 845 `UOContent.Tests`, 869 `Server.Tests`. --- .../Tests/Mobiles/AI/GuardFollowTests.cs | 5 +- .../Tests/Mobiles/AI/PetOrderTests.cs | 728 +++++++++++++- .../Tests/Mobiles/AI/PetPacingTests.cs | 123 ++- .../Tests/Mobiles/AI/PetRetaliationTests.cs | 164 +++ .../Tests/Mobiles/AI/PetTestStub.cs | 17 + .../UOContent/Gumps/ConfirmReleaseGump.cs | 3 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 18 + .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 16 +- .../Mobiles/AI/BaseAI/InternalEntry.cs | 16 +- .../UOContent/Mobiles/AI/BaseAI/OnSpeech.cs | 110 ++- .../UOContent/Mobiles/AI/BaseAI/PetLogin.cs | 33 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 280 ------ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 933 +++++++++++------- .../Mobiles/AI/BaseAI/TransferItem.cs | 5 +- Projects/UOContent/Mobiles/BaseCreature.cs | 160 ++- .../Mobiles/Monsters/Misc/Melee/Golem.cs | 2 - .../Reptile/Magic/SerpentineDragon.cs | 4 +- .../modernuo-content-patterns.md | 3 +- dev-docs/content-patterns.md | 11 + 20 files changed, 1841 insertions(+), 792 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs delete mode 100644 Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index db5225359..6765b07f9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -45,8 +45,9 @@ public class GuardFollowTests // 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); + // The return is paced by FollowMoveSpeed; the think clock stays on the active value + // and the per-step speed flip must not undo either. + Assert.Equal(0.2, currentSpeed); Assert.Equal(0.1, currentMoveSpeed); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index fbc6a2f81..91acee5d9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -252,8 +252,8 @@ public class PetOrderTests : IDisposable var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); var followers = master.Followers; - // What the loyalty drain calls when loyalty reaches zero. - pet.AIObject.DoOrderRelease(); + // What the loyalty drain assigns when loyalty reaches zero. + pet.ControlOrder = OrderType.Release; Assert.False(pet.Controlled); Assert.Null(pet.ControlMaster); @@ -270,7 +270,7 @@ public class PetOrderTests : IDisposable pet.Home = new Point3D(800, 800, 0); // simulate a stale anchor pet.Spawner = null; - pet.AIObject.DoOrderRelease(); + pet.ControlOrder = OrderType.Release; Assert.Equal(loc, pet.Home); // released where it stands, not the stale point } @@ -296,6 +296,67 @@ public class PetOrderTests : IDisposable Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); } + [Fact] + public void Login_RestoredStay_KeepsItsPostAnchor() + { + var post = new Point3D(1005, 1005, 0); + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post); + pet.ControlOrder = OrderType.Stay; // Home = post + pet.ChangeAIType(pet.AI); // what AfterDeserialization does: fresh AI, PersistentOrder = None + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + + PetLoginHandler.DeriveFollowerOrders(master); // master within 12 tiles + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + Assert.Equal(post, pet.Home); // not zeroed by a proximity-derived Follow + } + + [Fact] + public void Login_RestoredNone_NearMaster_IssuesFollow() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + pet.ControlOrder = OrderType.Stop; // -> None, no standing order + pet.ChangeAIType(pet.AI); + master.Hidden = true; + + PetLoginHandler.DeriveFollowerOrders(master); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Same(master, pet.ControlTarget); + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + Assert.True(master.Hidden); // system-issued: nobody revealed + } + + [Fact] + public void Login_RestoredAttack_FarFromMaster_IssuesStay() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1040, 1000, 0)); + pet.ControlOrder = OrderType.Attack; // rests with no valid target + pet.ChangeAIType(pet.AI); + + PetLoginHandler.DeriveFollowerOrders(master); + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(pet.Location, pet.Home); + } + + [Fact] + public void Login_RestoredAttack_NearMaster_FollowsTheMaster_NotTheVictim() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = SpawnPlayer(new Point3D(1003, 1000, 0)); + pet.IssueOrder(OrderType.Attack, master, victim); // saved mid-fight: ControlTarget = victim + pet.ChangeAIType(pet.AI); // post-load fresh AI + + PetLoginHandler.DeriveFollowerOrders(master); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Same(master, pet.ControlTarget); + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + } + [Fact] public void Stop_WhileFollowing_CancelsToIdle_NonML() { @@ -318,4 +379,665 @@ public class PetOrderTests : IDisposable Core.Expansion = previous; } } + // The parameterless ctor fully initializes a player; the Serial ctor leaves that to Deserialize. + private PlayerMobile SpawnPlayer(Point3D loc) + { + var pm = new PlayerMobile { Player = true }; + pm.MoveToWorld(loc, Map.Felucca); + _created.Add(pm); + return pm; + } + + // Administrative commands are not a change of what the pet is doing: it keeps fighting. + [Fact] + public void Drop_MidAttack_KeepsTheAttackAndItsTarget() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlOrder = OrderType.Follow; // standing order + pet.IssueOrder(OrderType.Attack, master, victim); + + pet.IssueOrder(OrderType.Drop, master); + + Assert.Equal(OrderType.Attack, pet.ControlOrder); + Assert.Same(victim, pet.ControlTarget); + Assert.Same(victim, pet.Combatant); + } + + [Fact] + public void Rename_MidAttack_KeepsTheAttack() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlOrder = OrderType.Follow; + pet.IssueOrder(OrderType.Attack, master, victim); + + pet.IssueOrder(OrderType.Rename, master); + + Assert.Equal(OrderType.Attack, pet.ControlOrder); + Assert.Same(victim, pet.ControlTarget); + } + + [Fact] + public void FriendRefusal_MidAttack_KeepsTheAttack() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); // already a friend -> refusal + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlOrder = OrderType.Follow; + pet.IssueOrder(OrderType.Attack, master, victim); + + pet.IssueOrder(OrderType.Friend, master, friend); + + Assert.Equal(OrderType.Attack, pet.ControlOrder); + Assert.Same(victim, pet.ControlTarget); + } + + // Nothing to resume into: the interrupted attack's target is gone. + [Fact] + public void Drop_MidAttack_WithTheTargetGone_FallsBackToTheStandingOrder() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlOrder = OrderType.Follow; + pet.IssueOrder(OrderType.Attack, master, victim); + victim.Delete(); + + pet.IssueOrder(OrderType.Drop, master); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + } + + // Resuming an attack must not re-run the aggression that ordering it performed. + [Fact] + public void Drop_MidAttack_DoesNotRepeatTheHarm() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlOrder = OrderType.Follow; + pet.IssueOrder(OrderType.Attack, master, victim); + var aggressors = victim.Aggressors.Count; + + pet.IssueOrder(OrderType.Drop, master); + + Assert.Equal(aggressors, victim.Aggressors.Count); + } + + [Fact] + public void Friend_Refused_RestsAtPersistentOrder_AndObeyDoesNotRepeat() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; // persistent = Follow + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); // already a friend -> refusal 1049691 + + pet.IssueOrder(OrderType.Friend, pet.ControlMaster, friend); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); // never rests at Friend + Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder)); + + // Obey must not repeat the refusal. + pet.AIObject.Obey(); + Assert.Equal(OrderType.Follow, pet.ControlOrder); + } + + [Fact] + public void Unfriend_OfNonFriend_RestsAtPersistentOrder() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Stay; // persistent = Stay + var stranger = SpawnPlayer(new Point3D(1002, 1000, 0)); + + pet.IssueOrder(OrderType.Unfriend, pet.ControlMaster, stranger); + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Friend_Accepted_LeavesTheStandingOrderAlone() + { + var post = new Point3D(1001, 1000, 0); + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post); + pet.ControlOrder = OrderType.Stay; // the owner's standing order, anchored at the post + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + + pet.IssueOrder(OrderType.Friend, master, friend); + + Assert.True(pet.IsPetFriend(friend)); + Assert.Equal(OrderType.Stay, pet.ControlOrder); // still staying + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); // owner's order not rewritten + Assert.Equal(post, pet.Home); // and not re-anchored + } + + [Fact] + public void Unfriend_Accepted_LeavesTheStandingOrderAlone() + { + var post = new Point3D(1001, 1000, 0); + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + pet.ControlOrder = OrderType.Stay; + + pet.IssueOrder(OrderType.Unfriend, master, friend); + + Assert.False(pet.IsPetFriend(friend)); + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + Assert.Equal(post, pet.Home); + } + + [Fact] + public void Rename_RestsAtARestableOrder() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + + pet.IssueOrder(OrderType.Rename, master); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder)); + } + + [Fact] + public void Drop_OnAPetThatCannotDrop_StillResolves() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Stay; + pet.IsDeadPet = true; // refuses to drop + + pet.IssueOrder(OrderType.Drop, master); + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + } + + [Fact] + public void Stop_ResolvesToARestableOrder_FromEveryPrevious() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + OrderType[] previousOrders = [OrderType.Come, OrderType.Attack, OrderType.Guard, OrderType.Follow, OrderType.Stay, OrderType.None]; + + for (var i = 0; i < previousOrders.Length; i++) + { + pet.ControlOrder = previousOrders[i]; + pet.ControlOrder = OrderType.Stop; + Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder)); + Assert.NotEqual(OrderType.Stop, pet.ControlOrder); + } + } + + [Fact] + public void IssueOrder_RevealsTheIssuer_NeverTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + master.Hidden = true; + friend.Hidden = true; + + pet.IssueOrder(OrderType.Stay, friend); + + Assert.False(friend.Hidden); + Assert.True(master.Hidden); + } + + [Fact] + public void SystemIssuedOrder_RevealsNobody() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + master.Hidden = true; + + pet.ControlOrder = OrderType.Follow; // raw assignment = system-issued + + Assert.True(master.Hidden); + } + + [Fact] + public void EndPickTarget_Attack_SetsCombatantAndFocus() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = SpawnPlayer(new Point3D(1003, 1000, 0)); + + pet.AIObject.EndPickTarget(master, victim, OrderType.Attack); + + Assert.Equal(OrderType.Attack, pet.ControlOrder); + Assert.Same(victim, pet.ControlTarget); + Assert.Same(victim, pet.Combatant); + Assert.Same(victim, pet.FocusMob); + Assert.True(pet.Warmode); + Assert.Equal(1, pet.CombatantSets); // the Issue phase is the only writer + + pet.AIObject.Obey(); // the tick does not rewrite it + Assert.Equal(1, pet.CombatantSets); + } + + [Fact] + public void ReIssuedAttack_OnTheSameTarget_DoesNotRewriteCombatantOrFlapWarmode() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = SpawnPlayer(new Point3D(1003, 1000, 0)); + + pet.IssueOrder(OrderType.Attack, master, victim); + Assert.Equal(1, pet.CombatantSets); + Assert.True(pet.Warmode); + + // Dropping Warmode would null Combatant and make the re-issue replay DoHarmful. + pet.IssueOrder(OrderType.Attack, master, victim); + + Assert.Equal(1, pet.CombatantSets); + Assert.True(pet.Warmode); + Assert.Same(victim, pet.Combatant); + Assert.Same(victim, pet.FocusMob); + } + + [Fact] + public void OrderedAttack_ReassertsTheCommandedTarget_AfterAnAggressorStealsCombatant() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var victim = SpawnPlayer(new Point3D(1003, 1000, 0)); + var other = SpawnPlayer(new Point3D(1002, 1000, 0)); + + pet.IssueOrder(OrderType.Attack, master, victim); + + // what OnAggressiveAction does + pet.Combatant = other; + Assert.Same(other, pet.Combatant); + + pet.AIObject.Obey(); // the tick puts the kill order back on the commanded target + + Assert.Same(victim, pet.Combatant); + Assert.Equal(OrderType.Attack, pet.ControlOrder); + } + + [Fact] + public void Rename_WhileFollowing_KeepsFollowingTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.IssueOrder(OrderType.Follow, master, master); + + pet.IssueOrder(OrderType.Rename, master); // the menu passes no target + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Same(master, pet.ControlTarget); // restored, not null + + pet.AIObject.Obey(); + Assert.Equal(OrderType.Follow, pet.ControlOrder); // no "no one to follow" -> None + } + + [Fact] + public void TransferRefused_ResumesFollowingTheMaster_NotTheRecipient() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.IssueOrder(OrderType.Follow, master, master); + var recipient = SpawnPlayer(new Point3D(1002, 1000, 0)); // no NetState -> the transfer is refused + + pet.IssueOrder(OrderType.Transfer, master, recipient); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Same(master, pet.ControlTarget); + Assert.True(pet.Controlled); + Assert.Same(master, pet.ControlMaster); + } + + [Fact] + public void SameOrderTwice_ReRunsIssue() + { + var postA = new Point3D(1005, 1005, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), postA); + pet.ControlOrder = OrderType.Stay; // Home = A + pet.MoveToWorld(new Point3D(1050, 1050, 0), pet.Map); + + pet.ControlOrder = OrderType.Stay; // reissued: re-anchor + + Assert.Equal(pet.Location, pet.Home); + } + + [Fact] + public void LoyaltyRelease_AndManualRelease_ProduceTheSameEndState() + { + var (masterA, petA) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var (masterB, petB) = Spawn(new Point3D(1100, 1100, 0), new Point3D(1101, 1100, 0)); + petA.Name = "Rex"; + petB.Name = "Rex"; + petA.IsBonded = true; + petB.IsBonded = true; + + petA.IssueOrder(OrderType.Release, masterA); // player + petB.ControlOrder = OrderType.Release; // what the loyalty drain does + + PetTestStub[] pets = [petA, petB]; + + for (var i = 0; i < pets.Length; i++) + { + var pet = pets[i]; + Assert.False(pet.Controlled); + Assert.Null(pet.ControlMaster); + Assert.False(pet.IsBonded); + Assert.Null(pet.Name); + Assert.Equal(OrderType.None, pet.ControlOrder); + Assert.True(pet.PendingDeleteTimer?.Running); + Assert.Equal(pet.Location, pet.Home); + } + + Assert.Equal(masterA.Followers, masterB.Followers); + } + + [Fact] + public void SummonedPet_Released_IsKilledNotReleased() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Summoned = true; + pet.SummonMaster = master; + + pet.ControlOrder = OrderType.Release; + + Assert.True(pet.Deleted || !pet.Alive); + } + + [Fact] + public void Stop_WithNoStandingOrder_IdlesAnchoredWhereItStands() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + Assert.Equal(OrderType.Come, pet.ControlOrder); // fresh tame: no standing order, Home = Zero + Assert.Equal(Point3D.Zero, pet.Home); + + pet.ControlOrder = OrderType.Stop; // what a vendor does after SetControlMaster(buyer) + + Assert.Equal(OrderType.None, pet.ControlOrder); + Assert.Equal(pet.Location, pet.Home); // anchored: no unbounded wander + } + + [Fact] + public void Release_ClearsFriendsAndTheStandingOrder() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + pet.ControlOrder = OrderType.Guard; // persistent = Guard + + pet.IssueOrder(OrderType.Release, master); + + Assert.False(pet.IsPetFriend(friend)); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + } + + [Fact] + public void PetDeath_IssuesFollowMaster_WithoutRevealingAnyone() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.IsBonded = true; + pet.ControlOrder = OrderType.Stay; + pet.ControlTarget = null; + master.Hidden = true; + + pet.Kill(); // bonded pet death -> IsDeadPet, follows the master + + Assert.True(pet.IsDeadPet); + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Same(master, pet.ControlTarget); + Assert.True(master.Hidden); + Assert.False(pet.Warmode); + } + + [Fact] + public void ObeyOnALegacyTransientOrder_FallsBackToPersistent() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; // persistent = Follow + + // a pre-refactor save resting at Rename + var field = typeof(BaseCreature).GetField("_controlOrder", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + field!.SetValue(pet, OrderType.Rename); + Assert.Equal(OrderType.Rename, pet.ControlOrder); + + pet.AIObject.Obey(); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + } + + [Fact] + public void SpeechCommand_FromAFriend_RevealsTheFriend_NotTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + master.Hidden = true; + friend.Hidden = true; + + // "all stay" keyword 0x170 + pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "all stay", MessageType.Regular, 0x3B2, [0x170])); + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.False(friend.Hidden); + Assert.True(master.Hidden); + } + + [Fact] + public void ContextMenuCommand_FromAFriend_RevealsTheFriend_NotTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + master.Hidden = true; + friend.Hidden = true; + + new InternalEntry(3006114, 14, OrderType.Stay, true).OnClick(friend, pet); // Command: Stay + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.False(friend.Hidden); + Assert.True(master.Hidden); + } + + [Fact] + public void ContextMenuCommand_FromAFriend_RefusesNonFriendOrders() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + pet.ControlOrder = OrderType.Follow; + + new InternalEntry(3006107, 14, OrderType.Guard, true).OnClick(friend, pet); // Command: Guard + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + } + + [Fact] + public void ContextMenuRename_LeavesThePetOnARestableOrder() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + + new InternalEntry(3006098, 14, OrderType.Rename, true).OnClick(master, pet); // Rename + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + } + + // Come with no standing order (fresh tame or post-load) must settle into one. + [Fact] + public void RestingCome_WithNoStandingOrder_SettlesIntoStayBesideTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + Assert.Equal(OrderType.Come, pet.ControlOrder); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + + pet.AIObject.Obey(); // within 2 tiles -> Stay + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + Assert.Equal(pet.Location, pet.Home); + } + + // Load never runs the Issue phase. Home (field 12) is read before ControlOrder (18); with an AI + // present, a setter-routed load would re-anchor Home to the restored Location. + [Fact] + public void ControlOrder_RoundTrips_AndLoadDoesNotRunIssue() + { + var post = new Point3D(1001, 1000, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post); + pet.ControlOrder = OrderType.Stay; // Home = post + pet.MoveToWorld(new Point3D(1020, 1000, 0), pet.Map); // displaced: Home != Location + Assert.Equal(post, pet.Home); + + var writer = new BufferWriter(true); + pet.Serialize(writer); + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new PetTestStub(World.NewMobile); + _created.Add(copy); + copy.ChangeAIType(AIType.AI_Animal); // the Issue gate is ai != null + + copy.Deserialize(new BufferReader(buffer)); + + Assert.Equal(OrderType.Stay, copy.ControlOrder); + Assert.Equal(new Point3D(1020, 1000, 0), copy.Location); + Assert.Equal(post, copy.Home); // not re-anchored + } + + [Fact] + public void SpeechCommand_WithoutThePetsName_IsIgnored() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Name = "Rex"; + pet.ControlOrder = OrderType.Stay; + + // bare "come" (keyword 0x155) with no name: not for this pet + pet.AIObject.OnSpeech(new SpeechEventArgs(master, "come", MessageType.Regular, 0x3B2, [0x155])); + Assert.Equal(OrderType.Stay, pet.ControlOrder); + + pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex come", MessageType.Regular, 0x3B2, [0x155])); + Assert.Equal(OrderType.Come, pet.ControlOrder); + } + + [Fact] + public void AllCommand_IssuesOnce() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Name = "Rex"; + pet.ControlOrder = OrderType.Follow; + var post = pet.Location; + + // The client emits both 0x170 ("all stay") and 0x16F ("*stay") for "all stay". + pet.AIObject.OnSpeech(new SpeechEventArgs(master, "all stay", MessageType.Regular, 0x3B2, [0x170, 0x16F])); + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(post, pet.Home); + // The named keyword alone must be ignored: the speech starts with "all", not the name. + pet.MoveToWorld(new Point3D(1010, 1010, 0), pet.Map); + pet.AIObject.OnSpeech(new SpeechEventArgs(master, "all stay", MessageType.Regular, 0x3B2, [0x16F])); + Assert.Equal(post, pet.Home); + } + + [Fact] + public void SpeechCommand_FromAFriend_CannotComeGuardOrDrop() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Name = "Rex"; + var friend = SpawnPlayer(new Point3D(1002, 1000, 0)); + pet.AddPetFriend(friend); + pet.ControlOrder = OrderType.Stay; + var post = pet.Home; + + pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex come", MessageType.Regular, 0x3B2, [0x155])); + Assert.Equal(OrderType.Stay, pet.ControlOrder); + + pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex guard", MessageType.Regular, 0x3B2, [0x15C])); + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + + pet.IsBonded = true; // CanDrop + pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex drop", MessageType.Regular, 0x3B2, [0x156])); + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(post, pet.Home); // never re-issued + + // The friend can still Stay/Follow/Stop. + pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex follow me", MessageType.Regular, 0x3B2, [0x163])); + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Same(friend, pet.ControlTarget); + } + + [Fact] + public void GMObey_TakesControlOfACommandablePet() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Name = "Rex"; + var gm = SpawnPlayer(new Point3D(1002, 1000, 0)); + gm.AccessLevel = AccessLevel.GameMaster; + + pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, [])); + + Assert.Same(gm, pet.ControlMaster); + } + + [Fact] + public void GMAllObey_DoesNotTakeControlledPets() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Name = "Rex"; + var gm = SpawnPlayer(new Point3D(1002, 1000, 0)); + gm.AccessLevel = AccessLevel.GameMaster; + + // The mass form is for wild creatures; a controlled pet must be named. + pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "all obey", MessageType.Regular, 0x3B2, [])); + Assert.Same(master, pet.ControlMaster); + + pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, [])); + Assert.Same(gm, pet.ControlMaster); + } + + // Release is relinquishing control, not exerting it: no roll, so no loyalty either way. + [Fact] + public void MenuRelease_TouchesNoLoyalty() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Loyalty = 50; + + new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet); // Release + + Assert.Equal(50, pet.Loyalty); // no roll: neither the +1 for passing nor the -3 for failing + } + + [Fact] + public void SpeechRelease_TouchesNoLoyalty() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.Name = "Rex"; + pet.Loyalty = 50; + + pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex release", MessageType.Regular, 0x3B2, [0x16D])); + + Assert.Equal(50, pet.Loyalty); + } + + // A creature nobody can command is not released either. + [Fact] + public void MenuRelease_OnAnUncontrollablePet_IsRefusedWithoutCost() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.MinTameSkill = 120.0; // control chance at or below zero + master.Skills.AnimalTaming.Base = 0; + master.Skills.AnimalLore.Base = 0; + pet.Loyalty = 50; + + new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet); + + Assert.Equal(50, pet.Loyalty); // refused, but never punished + Assert.True(pet.Controlled); + } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index 217049b3d..12d978e6d 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -54,9 +54,126 @@ public class PetPacingTests : IDisposable Assert.Equal(0.2, pet.CurrentSpeed); } - // AOS: following the master sprints at a bespoke 0.1 on both clocks. + // The follow pace caps the step delay and leaves the think clock on the active value. [Fact] - public void FollowMaster_ObeySprints() + public void FollowMaster_PacesStepsWithoutInflatingTheThinkClock() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // fixture era is EJ + + Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace + Assert.Equal(0.1, pet.CurrentMoveSpeed); // capped at the follow pace + } + + // A creature configured faster than the follow pace keeps its own. + [Fact] + public void FollowMaster_KeepsAFasterConfiguredPace() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.05, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + Assert.Equal(0.05, pet.CurrentMoveSpeed); + } + + // The move-clock override survives the order: it is capped while following, not overwritten. + [Fact] + public void FollowMaster_LeavesTheConfiguredMoveClockAlone() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + pet.AIObject.Obey(); + pet.ControlOrder = OrderType.Stay; + + Assert.Equal(0.3, pet.ActiveMoveSpeed); + Assert.Equal(0.9, pet.PassiveMoveSpeed); + Assert.Equal(0.9, pet.CurrentMoveSpeed); // resting on its own passive pace again + } + + // Pre-AOS pets follow at their own pace; nothing caps them. + [Fact] + public void FollowMaster_PreAOS_KeepsItsOwnPace() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + finally + { + Core.Expansion = previous; + } + } + + private sealed class SprintingPet : PetTestStub + { + public override double FollowMoveSpeed => 0.125; + } + + // A shard paces follows in any era by overriding the property, not by patching the AI. + [Fact] + public void FollowMoveSpeedOverride_PacesFollowsInAnyEra() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new SprintingPet(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + _created.Add(pet); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + Assert.Equal(0.125, pet.CurrentMoveSpeed); + } + finally + { + Core.Expansion = previous; + } + } + + // A guarding pet outside guard range closes at the follow pace, thinking on its active clock. + [Fact] + public void GuardReturn_PacesStepsWithoutInflatingTheThinkClock() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1006, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlOrder = OrderType.Guard; + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.1, pet.CurrentMoveSpeed); + } + + // Obeying the follow order must not write the pace into either clock. + [Fact] + public void FollowMaster_ObeyKeepsTheThinkClockActive() { var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); pet.SetMoveSpeed(0.3, 0.9); @@ -66,7 +183,7 @@ public class PetPacingTests : IDisposable pet.ControlOrder = OrderType.Follow; // fixture era is EJ pet.AIObject.Obey(); - Assert.Equal(0.1, pet.CurrentSpeed); + Assert.Equal(0.2, pet.CurrentSpeed); Assert.Equal(0.1, pet.CurrentMoveSpeed); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs new file mode 100644 index 000000000..00316a351 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Publish 51 (26 March 2008): a pet told to follow, come, stay or stop "will not attack +// anything, even if it is attacked". Guard and attack are unaffected. +[Collection("Sequential UOContent Tests")] +public class PetRetaliationTests : IDisposable +{ + private readonly List _created = new(); + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private sealed class StandDownPet : PetTestStub + { + public override bool StandsDownOnCommand => true; + } + + private sealed class FightBackPet : PetTestStub + { + public override bool StandsDownOnCommand => false; + } + + private (PlayerMobile master, T pet) Spawn() where T : BaseCreature, new() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new T(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + pet.AIObject.AITimer?.Stop(); + _created.Add(pet); + + return (master, pet); + } + + private void Order(BaseCreature pet, Mobile master, OrderType order) + { + if (order == OrderType.None) // Publish 51's "stop": stops, may wander, will not attack + { + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + pet.ControlOrder = OrderType.Stop; + return; + } + + pet.ControlTarget = master; + pet.ControlOrder = order; + } + + private BaseCreature Attack(BaseCreature pet) + { + var attacker = new PetTestStub(); + attacker.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map); + _created.Add(attacker); + pet.AIObject.AITimer?.Stop(); + + attacker.Combatant = pet; // a mob starts attacking the pet + return attacker; + } + + [Theory] + [InlineData(OrderType.Follow)] + [InlineData(OrderType.Come)] + [InlineData(OrderType.Stay)] + [InlineData(OrderType.None)] // stopped + public void StandDownOrder_IgnoresTheAttacker(OrderType order) + { + var (master, pet) = Spawn(); + Order(pet, master, order); + var resting = pet.ControlOrder; + + Attack(pet); + + Assert.Equal(resting, pet.ControlOrder); // never converts to Attack + Assert.Null(pet.Combatant); + Assert.False(pet.Warmode); + } + + [Theory] + [InlineData(OrderType.Follow)] + [InlineData(OrderType.Come)] + [InlineData(OrderType.Stay)] + [InlineData(OrderType.None)] + public void WithoutStandDown_TheSameOrdersRetaliate(OrderType order) + { + var (master, pet) = Spawn(); + Order(pet, master, order); + + var attacker = Attack(pet); + + Assert.Equal(OrderType.Attack, pet.ControlOrder); + Assert.Same(attacker, pet.Combatant); + } + + // No damage callback may put a stand-down pet back into combat behind the policy's back. + [Theory] + [InlineData(Expansion.AOS, false)] + [InlineData(Expansion.AOS, true)] + [InlineData(Expansion.ML, false)] + [InlineData(Expansion.ML, true)] + public void StandDownPet_StaysDown_ThroughRepeatedDamage(Expansion era, bool spellDamage) + { + var previous = Core.Expansion; + + try + { + Core.Expansion = era; + var (master, pet) = Spawn(); + Order(pet, master, OrderType.Follow); + var attacker = Attack(pet); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); // the initial aggression stood down + + for (var i = 0; i < 500 && pet.ControlOrder == OrderType.Follow; i++) + { + if (spellDamage) + { + pet.OnDamagedBySpell(attacker, 1); + } + else + { + pet.OnDamage(1, attacker, false); + } + } + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Null(pet.Combatant); + } + finally + { + Core.Expansion = previous; + } + } + + // "Guard: the pet should guard as it does currently." + [Fact] + public void GuardingPet_StillFights_UnderStandDown() + { + var (master, pet) = Spawn(); + Order(pet, master, OrderType.Guard); + + var attacker = Attack(pet); + + Assert.Equal(OrderType.Guard, pet.ControlOrder); + Assert.Same(attacker, pet.Combatant); + Assert.True(pet.Warmode); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs index a759cc024..d15228590 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -23,6 +23,23 @@ public class PetTestStub : BaseCreature passiveSpeed = 0.4; } + // Effective (value-changing, non-null) Combatant writes; a re-issue on the same target must not add one. + public int CombatantSets { get; private set; } + + public override Mobile Combatant + { + get => base.Combatant; + set + { + if (value != null && base.Combatant != value) + { + CombatantSets++; + } + + base.Combatant = value; + } + } + public override bool CheckIdle() => ForceIdle || base.CheckIdle(); public PetTestStub(Serial serial) : base(serial) diff --git a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs index 1863881a1..aa3bdc4bc 100644 --- a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs +++ b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs @@ -42,7 +42,6 @@ public class ConfirmReleaseGump : StaticGump return; } - _pet.ControlTarget = null; - _pet.ControlOrder = OrderType.Release; + _pet.IssueOrder(OrderType.Release, _from); } } diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index f7b118df6..5f594b399 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -502,7 +502,7 @@ public partial class BaseTalisman : Item, IAosItem ); mob.Summoned = false; - mob.ControlOrder = OrderType.Friend; + mob.IssueOrder(OrderType.Follow, null, from); _creature = mob; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 8b2a16cf2..381958d68 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -612,6 +612,24 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; + // Following its master, or guarding from outside guard range. FollowMoveSpeed caps the step + // delay while this holds. + public bool IsPacingToMaster() + { + if (!Mobile.Controlled || Mobile.Combatant != null) + { + return false; + } + + return Mobile.ControlOrder switch + { + OrderType.Follow => Mobile.ControlTarget == Mobile.ControlMaster, + OrderType.Guard => Mobile.ControlMaster?.Deleted == false && + (int)Mobile.GetDistanceToSqrt(Mobile.ControlMaster) > GuardRange, + _ => false + }; + } + // A pet executing a movement order outside combat; its order handler owns its speed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsObeyingMoveOrder() => diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b8753d10..e7b16be9c 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,6 +26,9 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // How far a guarding pet may drift from its master before it closes the gap. + internal const int GuardRange = 3; + // 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; @@ -35,7 +38,6 @@ public abstract partial class BaseAI private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; - public Mobile _commandIssuer; private Mobile _lkpTarget; private Point3D _lkpLocation; @@ -160,15 +162,7 @@ public abstract partial class BaseAI if (Mobile.CheckControlChance(from)) { - Mobile.ControlTarget = target; - Mobile.ControlOrder = order; - - if (order == OrderType.Attack) - { - Mobile.FocusMob = target; - Mobile.Combatant = target; - Action = ActionType.Combat; - } + Mobile.IssueOrder(order, from, target); } } @@ -188,7 +182,7 @@ public abstract partial class BaseAI return false; } - if (isFriend && order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop)) + if (isFriend && !IsFriendOrder(order)) { return false; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs b/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs index 9bee2edff..d245f91b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs @@ -47,7 +47,7 @@ internal sealed class InternalEntry : ContextMenuEntry return from.CheckAlive() && bc != null && !bc.Deleted && bc.Controlled; } - private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && _order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop; + private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && BaseAI.IsDeadPetOrder(_order); private static bool IsOwnerOrFriend(Mobile from, BaseCreature bc, out bool isFriend) { @@ -56,7 +56,7 @@ internal sealed class InternalEntry : ContextMenuEntry return isOwner || isFriend; } - private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && _order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop); + private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && !BaseAI.IsFriendOrder(_order); private void HandleOrder(Mobile from, BaseCreature bc) { @@ -99,9 +99,16 @@ internal sealed class InternalEntry : ContextMenuEntry private void HandleReleaseOrder(Mobile from, BaseCreature bc) { + // No roll: a refused one would only drain loyalty toward the involuntary release the + // drain performs anyway. Whoever can command the creature may dismiss it. + if (!bc.CanBeControlledBy(from)) + { + return; + } + if (bc.Summoned) { - HandleDefaultOrder(from, bc); + bc.IssueOrder(OrderType.Release, from); return; } @@ -112,8 +119,7 @@ internal sealed class InternalEntry : ContextMenuEntry { if (bc.CheckControlChance(from)) { - bc.ControlTarget = null; - bc.ControlOrder = _order; + bc.IssueOrder(_order, from); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index ce9af5bb7..7ce0994f6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -48,16 +48,23 @@ public abstract partial class BaseAI } } - if (Mobile.Controlled && Mobile.Commandable) + // Staff first, so " obey" reaches a controlled pet. + if (e.Mobile.AccessLevel >= AccessLevel.GameMaster && HandleGMCommands(e)) { - AllOnSpeechPet(e); - NamedOnSpeechPet(e); return; } - if (e.Mobile.AccessLevel >= AccessLevel.GameMaster) + if (Mobile.Controlled && Mobile.Commandable) { - HandleGMCommands(e); + // Exactly one handler per utterance: named addresses this pet, "all" every pet in range. + if (WasNamed(e.Speech)) + { + NamedOnSpeechPet(e); + } + else + { + AllOnSpeechPet(e); + } } } @@ -158,7 +165,7 @@ public abstract partial class BaseAI { case 0x164: // all come { - HandleComeCommand(e.Mobile, true); + HandleComeCommand(e.Mobile, isOwner); break; } case 0x165: // all follow @@ -169,7 +176,7 @@ public abstract partial class BaseAI case 0x166: // all guard case 0x16B: // all guard me { - HandleGuardCommand(e.Mobile, true); + HandleGuardCommand(e.Mobile, isOwner); break; } case 0x167: // all stop @@ -180,7 +187,7 @@ public abstract partial class BaseAI case 0x168: // all kill case 0x169: // all attack { - HandleAttackCommand(e.Mobile, true); + HandleAttackCommand(e.Mobile, isOwner); break; } case 0x16C: // all follow me @@ -230,12 +237,12 @@ public abstract partial class BaseAI { case 0x155: // *come { - HandleComeCommand(e.Mobile, true); + HandleComeCommand(e.Mobile, isOwner); break; } case 0x156: // *drop { - HandleDropCommand(e.Mobile, true, e.Speech); + HandleDropCommand(e.Mobile, isOwner); break; } case 0x15A: // *follow @@ -245,18 +252,18 @@ public abstract partial class BaseAI } case 0x15B: // *friend { - HandleFriendCommand(e.Mobile, true, e.Speech); + HandleFriendCommand(e.Mobile, isOwner); break; } case 0x15C: // *guard { - HandleGuardCommand(e.Mobile, true); + HandleGuardCommand(e.Mobile, isOwner); break; } case 0x15D: // *kill case 0x15E: // *attack { - HandleAttackCommand(e.Mobile, true); + HandleAttackCommand(e.Mobile, isOwner); break; } case 0x161: // *stop @@ -271,12 +278,12 @@ public abstract partial class BaseAI } case 0x16D: // *release { - HandleReleaseCommand(e.Mobile, true, e.Speech); + HandleReleaseCommand(e.Mobile, isOwner); break; } case 0x16E: // *transfer { - HandleTransferCommand(e.Mobile, true, e.Speech); + HandleTransferCommand(e.Mobile, isOwner); break; } case 0x16F: // *stay @@ -330,9 +337,7 @@ public abstract partial class BaseAI { if (isOwner && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Come; + Mobile.IssueOrder(OrderType.Come, from); } } @@ -340,9 +345,7 @@ public abstract partial class BaseAI { if (isOwner && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Guard; + Mobile.IssueOrder(OrderType.Guard, from); } } @@ -350,9 +353,7 @@ public abstract partial class BaseAI { if (Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = target; - Mobile.ControlOrder = order; + Mobile.IssueOrder(order, from, target); } } @@ -360,25 +361,21 @@ public abstract partial class BaseAI { if (isOwner) { - _commandIssuer = from; BeginPickTarget(from, OrderType.Attack); } } - private void HandleDropCommand(Mobile from, bool isOwner, string speech) + private void HandleDropCommand(Mobile from, bool isOwner) { - if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && WasNamed(speech) - && Mobile.CheckControlChance(from)) + if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Drop; + Mobile.IssueOrder(OrderType.Drop, from); } } - private void HandleFriendCommand(Mobile from, bool isOwner, string speech) + private void HandleFriendCommand(Mobile from, bool isOwner) { - if (isOwner && WasNamed(speech) && Mobile.CheckControlChance(from)) + if (isOwner && Mobile.CheckControlChance(from)) { if (Mobile.Summoned || Mobile is GrizzledMare) { @@ -398,29 +395,31 @@ public abstract partial class BaseAI } } - private void HandleReleaseCommand(Mobile from, bool isOwner, string speech) + private void HandleReleaseCommand(Mobile from, bool isOwner) { if (!isOwner) { return; } - if (WasNamed(speech) && Mobile.CheckControlChance(from)) + // No control roll: see InternalEntry.HandleReleaseOrder. + if (!Mobile.CanBeControlledBy(from)) { - if (!Mobile.Summoned) - { - from.SendGump(new ConfirmReleaseGump(from, Mobile)); - } - else - { - Mobile.ControlOrder = OrderType.Release; - } + return; } + + if (Mobile.Summoned) + { + Mobile.IssueOrder(OrderType.Release, from); + return; + } + + from.SendGump(new ConfirmReleaseGump(from, Mobile)); } - private void HandleTransferCommand(Mobile from, bool isOwner, string speech) + private void HandleTransferCommand(Mobile from, bool isOwner) { - if (isOwner && !Mobile.IsDeadPet && WasNamed(speech) && Mobile.CheckControlChance(from)) + if (isOwner && !Mobile.IsDeadPet && Mobile.CheckControlChance(from)) { if (Mobile.Summoned || Mobile is GrizzledMare) { @@ -440,18 +439,23 @@ public abstract partial class BaseAI } } - private void HandleGMCommands(SpeechEventArgs e) + private bool HandleGMCommands(SpeechEventArgs e) { this.DebugSayFormatted($"Command is from GM: {e.Mobile.Name}, Target: {Mobile.ControlTarget?.Name ?? "None or Unknown"}"); - if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey")) + // "all obey" is for wild creatures; a controlled pet must be named. + if (!Mobile.FindMyName(e.Speech, !Mobile.Controlled) || !e.Speech.InsensitiveContains("obey")) { - Mobile.SetControlMaster(e.Mobile); - - if (Mobile.SummonMaster != null) - { - Mobile.SummonMaster = e.Mobile; - } + return false; } + + Mobile.SetControlMaster(e.Mobile); + + if (Mobile.SummonMaster != null) + { + Mobile.SummonMaster = e.Mobile; + } + + return true; } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs index 446b25b21..16ca9648a 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs @@ -25,9 +25,8 @@ public static class PetLoginHandler [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] public static void OnLogin(PlayerMobile pm) => DeriveFollowerOrders(pm); - // The persistent command is runtime-only and reset to None on load. When the master logs - // in we give each controlled pet that still has no standing command a sane one, inferred - // from proximity: near master -> Follow, otherwise Stay. + // PersistentOrder is runtime-only; ControlOrder and Home are saved. A pet saved mid-transient + // gets Follow near the master, else Stay. public static void DeriveFollowerOrders(PlayerMobile master) { if (master?.AllFollowers == null) @@ -37,14 +36,30 @@ public static class PetLoginHandler foreach (var follower in master.AllFollowers) { - if (follower is BaseCreature { Controlled: true, Deleted: false } bc - && bc.ControlMaster == master - && bc.AIObject is { } ai - && ai.PersistentOrder == OrderType.None) + if (follower is not BaseCreature { Controlled: true, Deleted: false } bc + || bc.ControlMaster != master + || bc.AIObject is not { } ai + || ai.PersistentOrder != OrderType.None) { - var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; - ai.SetPersistentOrder(near ? OrderType.Follow : OrderType.Stay); + continue; } + + var restored = bc.ControlOrder; + + if (restored is OrderType.Stay or OrderType.Follow or OrderType.Guard) + { + ai.RestorePersistentOrder(restored); + continue; + } + + var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; + var derived = near ? OrderType.Follow : OrderType.Stay; + + // ControlTarget first: SetPersistentOrder records it as the Follow target, and a + // mid-Attack save still holds the victim. + bc.ControlTarget = near ? master : null; + ai.SetPersistentOrder(derived); + bc.SetControlOrder(derived, null, true); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs deleted file mode 100644 index a132bc896..000000000 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ /dev/null @@ -1,280 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: PetOrderHandlers.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - ************************************************************************/ - -using System; - -namespace Server.Mobiles; - -public abstract partial class BaseAI -{ - public virtual void OnCurrentOrderChanged(OrderType previous) - { - if (Mobile.Deleted || Mobile.ControlMaster?.Deleted != false) - { - return; - } - - AITimer.Prod(); - - switch (Mobile.ControlOrder) - { - case OrderType.None: - { - HandleNoOrder(); - break; - } - case OrderType.Come: - { - Mobile.SetCurrentSpeedToActive(); - break; - } - case OrderType.Drop: - case OrderType.Friend: - case OrderType.Unfriend: - { - break; - } - case OrderType.Release: - { - HandleReleaseOrder(); - break; - } - case OrderType.Stop: - { - // Stop is resolved into another order; it never rests as the active order. - ResolveStop(previous); - return; - } - case OrderType.Transfer: - { - HandleTransferOrder(); - break; - } - case OrderType.Stay: - { - HandleStayOrder(); - break; - } - case OrderType.Guard: - { - HandleGuardOrder(); - break; - } - case OrderType.Attack: - { - HandleAttackOrder(); - break; - } - case OrderType.Follow: - { - HandleFollowOrder(); - break; - } - case OrderType.Rename: - { - HandleRenameOrder(); - break; - } - } - - // A freshly issued standing command becomes the persistent fallback and (re)anchors - // Home. Skipped while resuming a fallback so a resume never re-anchors. See - // ResumePersistentOrder. - if (!_resolvingOrder && Mobile.ControlOrder is OrderType.Stay or OrderType.Follow or OrderType.Guard) - { - SetPersistentOrder(Mobile.ControlOrder); - } - } - - // "Stop" cancels the active order, mapping to a resting order based on what the pet was - // doing: Attack/Come/etc. -> resume the persistent command; Follow/Guard -> cancel to idle - // (None) where it stands; Stay -> remain staying at its post. - private void ResolveStop(OrderType previous) - { - _commandIssuer?.RevealingAction(); - _commandIssuer = null; - Mobile.ControlTarget = null; - - switch (previous) - { - case OrderType.Stay: - { - _resolvingOrder = true; - Mobile.ControlOrder = OrderType.Stay; // remain staying; anchor untouched - _resolvingOrder = false; - break; - } - case OrderType.Follow: - case OrderType.Guard: - { - SetPersistentOrder(OrderType.None); // cancel standing order; anchor = current - _resolvingOrder = true; - Mobile.ControlOrder = OrderType.None; // idle - _resolvingOrder = false; - break; - } - default: // Attack / Come / Drop / None / etc. -> resume the standing order - { - ResumePersistentOrder(); - break; - } - } - } - - private void HandleNoOrder() - { - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - } - - private void HandleTransferOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - } - - private void HandleGuardOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - - // Resuming the persistent order must not replay the flourish. - if (!_resolvingOrder) - { - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. - } - - _commandIssuer = null; - } - - private void HandleAttackOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - - if (Mobile.ControlTarget != null && - !Mobile.ControlTarget.Deleted && - Mobile.ControlTarget.Alive) - { - Mobile.FocusMob = Mobile.ControlTarget; - Mobile.Combatant = Mobile.ControlTarget; - } - else - { - Mobile.FocusMob = null; - Mobile.Combatant = null; - } - - Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - Mobile.PlaySound(Mobile.GetAttackSound()); - _commandIssuer = null; - } - - private void HandleFollowOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToActive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - } - - private void HandleStayOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - 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. - } - - private void HandleReleaseOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - if (Mobile.Summoned) - { - Mobile.Kill(); - return; - } - - if (!string.IsNullOrEmpty(Mobile.Name)) - { - Mobile.Name = null; - } - - _commandIssuer?.RevealingAction(); - Mobile.PlaySound(Mobile.GetIdleSound()); - DoOrderRelease(); - _commandIssuer = null; - } - - public virtual void HandleRenameOrder() - { - if (Mobile.Summoned) - { - Mobile.ControlMaster?.SendMessage("You cannot rename a summoned creature."); - } - else - { - Mobile.ControlMaster?.SendMessage("Change name on pet health bar."); - } - } -} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 3282493b8..1e751e9ab 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -19,61 +19,580 @@ namespace Server.Mobiles; public abstract partial class BaseAI { - // The standing command a pet falls back to when a transient order (Attack/Come/Drop) - // completes: None, Stay, Follow, or Guard. Runtime-only (not serialized); reset to None - // on load and derived from master proximity on login. See PetLoginHandler. + // Runtime-only: None after a load until PetLoginHandler derives it. internal OrderType PersistentOrder { get; private set; } = OrderType.None; - // Guards anchor/persistent derivation while we resume a fallback order, so a resume - // never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged. - private bool _resolvingOrder; + public static bool IsRestableOrder(OrderType order) => + order is OrderType.None or OrderType.Come or OrderType.Guard or OrderType.Attack or OrderType.Stay + or OrderType.Follow; - // Who a standing Follow follows: usually the master, but a pet friend can point it - // elsewhere ("all follow me"). Runtime-only, like PersistentOrder. - private Mobile _persistentFollowTarget; + public static bool IsFriendOrder(OrderType order) => + order is OrderType.Follow or OrderType.Stay or OrderType.Stop; + + // Everything else stands the pet down before it runs. + private static bool KeepsCombatPosture(OrderType order) => + order is OrderType.Attack or OrderType.Guard or OrderType.Drop or OrderType.Friend + or OrderType.Unfriend or OrderType.Rename; + + // Publish 51: told any of these, a pet "will not attack anything, even if it is attacked". + // Stop resolves to None, which is its resting form ("and may wander"). + public static bool IsStandDownOrder(OrderType order) => + order is OrderType.Follow or OrderType.Come or OrderType.Stay or OrderType.None; + + // Orders a dead bonded pet refuses. + public static bool IsDeadPetOrder(OrderType order) => + order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop; + + // A targeted command overwrites ControlTarget; a resumed Follow restores it from here. + private Mobile _persistentTarget; // The controlled-pet wander anchor (Home) is a pure function of the persistent command. internal void SetPersistentOrder(OrderType order) { PersistentOrder = order; - _persistentFollowTarget = order == OrderType.Follow ? Mobile.ControlTarget ?? Mobile.ControlMaster : null; + _persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null; Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location; } - // Resume the persistent command without re-deriving the persistent order or anchor. - private void ResumePersistentOrder() + // Adopt a saved standing order; Home and ControlTarget were saved with it. + internal void RestorePersistentOrder(OrderType order) { - // The ending order owns ControlTarget and leaves it cleared or pointing elsewhere; - // without a target DoOrderFollow cancels itself to idle on the next think. - if (PersistentOrder == OrderType.Follow) - { - Mobile.ControlTarget = _persistentFollowTarget?.Deleted == false - ? _persistentFollowTarget - : Mobile.ControlMaster; - } - - _resolvingOrder = true; - Mobile.ControlOrder = PersistentOrder; - _resolvingOrder = false; + PersistentOrder = order; + _persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null; } - public virtual bool Obey() => - !Mobile.Deleted && Mobile.ControlOrder switch + // The standing order is the fallback only for an interrupted order that cannot resume: a + // transient, or an attack whose target is gone. + private OrderType ResumeInterrupted(OrderType previous, Mobile interruptedTarget) + { + if (!IsRestableOrder(previous) || + previous == OrderType.Attack && IsInvalidControlTarget(interruptedTarget)) { - OrderType.None => DoOrderNone(), - OrderType.Come => DoOrderCome(), - OrderType.Drop => DoOrderDrop(), - OrderType.Friend => DoOrderFriend(), - OrderType.Unfriend => DoOrderUnfriend(), - OrderType.Guard => DoOrderGuard(), - OrderType.Attack => DoOrderAttack(), - OrderType.Release => DoOrderRelease(), - OrderType.Stay => DoOrderStay(), - OrderType.Stop => DoOrderStop(), - OrderType.Follow => DoOrderFollow(), - OrderType.Transfer => DoOrderTransfer(), - _ => false + return PersistentOrder; + } + + Mobile.ControlTarget = interruptedTarget; + return previous; + } + + // Resume the standing command without re-deriving it or re-anchoring Home. + private void ResumePersistentOrder() => Mobile.SetControlOrder(PersistentOrder, null, true); + + /// + /// Issue phase. is the only mobile revealed (null = system-issued); + /// marks a fallback to the standing order. Returns the order to rest in. + /// + public virtual OrderType IssueOrder( + OrderType order, OrderType previous, Mobile issuer, bool resuming, Mobile interruptedTarget + ) + { + if (Mobile.Deleted) + { + return order; + } + + AITimer.Prod(); + + issuer?.RevealingAction(); + + // Dropping Warmode nulls Combatant through the Mobile setter, which would turn Attack's + // single Combatant write into a re-write (DoHarmful again) and flap Guard's war stance. + Mobile.FocusMob = null; + + if (!KeepsCombatPosture(order)) + { + Mobile.Warmode = false; // also nulls Combatant via the setter + Mobile.Combatant = null; + } + + return order switch + { + OrderType.None => IssueNone(), + OrderType.Come => IssueCome(), + OrderType.Drop => IssueDrop(previous, interruptedTarget), + OrderType.Friend => IssueFriend(previous, interruptedTarget), + OrderType.Unfriend => IssueUnfriend(previous, interruptedTarget), + OrderType.Guard => IssueGuard(resuming), + OrderType.Attack => IssueAttack(resuming), + OrderType.Release => IssueRelease(), + OrderType.Stay => IssueStay(resuming), + OrderType.Stop => IssueStop(previous), + OrderType.Follow => IssueFollow(resuming), + OrderType.Transfer => IssueTransfer(), + OrderType.Rename => IssueRename(issuer, previous, interruptedTarget), + _ => PersistentOrder // Patrol and anything unimplemented }; + } + + private OrderType IssueNone() + { + Mobile.ControlTarget = null; + Mobile.SetCurrentSpeedToPassive(); + return OrderType.None; + } + + private OrderType IssueCome() + { + Mobile.SetCurrentSpeedToActive(); + return OrderType.Come; + } + + private OrderType IssueStay(bool resuming) + { + Mobile.SetCurrentSpeedToPassive(); + + if (resuming) + { + Mobile.ControlTarget = null; // a transient's target does not carry over + } + else + { + SetPersistentOrder(OrderType.Stay); // anchors Home at the post + Mobile.PlaySound(Mobile.GetIdleSound()); + } + + return OrderType.Stay; + } + + private OrderType IssueFollow(bool resuming) + { + Mobile.SetCurrentSpeedToActive(); + + if (resuming) + { + // the standing Follow's target, never a transient's + Mobile.ControlTarget = _persistentTarget?.Deleted == false ? _persistentTarget : Mobile.ControlMaster; + } + else + { + SetPersistentOrder(OrderType.Follow); // Home = Zero, remembers the target + Mobile.PlaySound(Mobile.GetIdleSound()); + } + + return OrderType.Follow; + } + + private OrderType IssueGuard(bool resuming) + { + Mobile.Warmode = true; // the guard order opens in war stance + Mobile.SetCurrentSpeedToActive(); + + if (resuming) + { + Mobile.ControlTarget = null; + } + else + { + SetPersistentOrder(OrderType.Guard); + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + + return OrderType.Guard; + } + + private OrderType IssueAttack(bool resuming) + { + var target = Mobile.ControlTarget; + var valid = target?.Deleted == false && target.Alive; + + Mobile.FocusMob = valid ? target : null; + Mobile.Combatant = valid ? target : null; // the one Combatant write of the Attack command + + if (valid) + { + Action = ActionType.Combat; + } + + Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); + + // A resumed attack is not a new command: no bark. The Combatant write above is + // idempotent (the setter early-outs unchanged), so its aggression is not repeated. + if (!resuming) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + } + + return OrderType.Attack; + } + + // Stop: Follow/Guard -> idle here; Stay -> keep the post; anything transient -> the standing order. + private OrderType IssueStop(OrderType previous) + { + Mobile.ControlTarget = null; + + switch (previous) + { + case OrderType.Stay: + { + return OrderType.Stay; // resumed: anchor untouched + } + case OrderType.Follow: + case OrderType.Guard: + { + SetPersistentOrder(OrderType.None); // cancel the standing order; idle anchor = here + return OrderType.None; + } + default: + { + // No standing order: idle here, anchored (a Zero Home wanders without bounds). + if (PersistentOrder == OrderType.None) + { + SetPersistentOrder(OrderType.None); + } + + return PersistentOrder; + } + } + } + + private OrderType IssueDrop(OrderType previous, Mobile interruptedTarget) + { + if (!Mobile.IsDeadPet && Mobile.CanDrop) + { + this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); + DropItems(); + } + + return ResumeInterrupted(previous, interruptedTarget); + } + + private void DropItems() + { + var pack = Mobile.Backpack; + + if (pack == null) + { + return; + } + + var items = pack.Items; + + for (var i = items.Count - 1; i >= 0; --i) + { + if (i < items.Count) + { + items[i].MoveToWorld(Mobile.Location, Mobile.Map); + } + } + } + + private OrderType IssueFriend(OrderType previous, Mobile interruptedTarget) + { + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false) + { + return ResumeInterrupted(previous, interruptedTarget); + } + + var youngFrom = from is PlayerMobile { Young: true }; + var youngTo = to is PlayerMobile { Young: true }; + + if (youngFrom && !youngTo) + { + from.SendLocalizedMessage(502040); + // As a young player, you may not friend pets to older players. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!youngFrom && youngTo) + { + from.SendLocalizedMessage(502041); + // As an older player, you may not friend pets to young players. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (to?.Deleted != false || from == to || !to.Player) + { + Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); + // *looks confused* + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!from.CanBeBeneficial(to, true)) + { + return ResumeInterrupted(previous, interruptedTarget); + } + + if (from.HasTrade || to.HasTrade) + { + (from.HasTrade ? from : to).SendLocalizedMessage(1070947); + // You cannot friend a pet with a trade pending + return ResumeInterrupted(previous, interruptedTarget); + } + + if (Mobile.IsPetFriend(to)) + { + from.SendLocalizedMessage(1049691); + // That person is already a friend. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!Mobile.AllowNewPetFriend) + { + from.SendLocalizedMessage(1005482); + // Your pet does not seem to be interested in making new friends right now. + return ResumeInterrupted(previous, interruptedTarget); + } + + from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}"); + // ~1_NAME~ will now accept movement commands from ~2_NAME~. + + to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}"); + // ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~. + // This creature will now consider you as a friend. + + Mobile.AddPetFriend(to); + + return ResumeInterrupted(previous, interruptedTarget); + } + + private OrderType IssueUnfriend(OrderType previous, Mobile interruptedTarget) + { + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) + { + Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); + // *looks confused* + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!Mobile.IsPetFriend(to)) + { + from.SendLocalizedMessage(1070953); + // That person is not a friend. + return ResumeInterrupted(previous, interruptedTarget); + } + + from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}"); + // ~1_NAME~ will no longer accept movement commands from ~2_NAME~. + + to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}"); + // ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~. + // This creature will no longer consider you as a friend. + + Mobile.RemovePetFriend(to); + + return ResumeInterrupted(previous, interruptedTarget); + } + + private OrderType IssueTransfer() + { + if (Mobile.IsDeadPet) + { + return PersistentOrder; + } + + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) + { + return PersistentOrder; + } + + this.DebugSayFormatted($"Beginning transfer with {to.Name}"); + + var youngFrom = from is PlayerMobile { Young: true }; + var youngTo = to is PlayerMobile { Young: true }; + + if (youngFrom && !youngTo) + { + from.SendLocalizedMessage(502040); + // As a young player, you may not friend pets to older players. + return PersistentOrder; + } + + if (!youngFrom && youngTo) + { + from.SendLocalizedMessage(502041); + // As an older player, you may not friend pets to young players. + return PersistentOrder; + } + + if (!Mobile.CanBeControlledBy(to)) + { + SendTransferRefusalMessages(from, to, 1043248, 1043249); + // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ + // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ + return PersistentOrder; + } + + if (!Mobile.CanBeControlledBy(from)) + { + SendTransferRefusalMessages(from, to, 1043250, 1043251); + // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ + // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ + return PersistentOrder; + } + + // The stand-down already cleared Combatant; the aggressor lists and the combat cooldown gate this. + if (Mobile.Aggressors.Count > 0 || Mobile.Aggressed.Count > 0 || Core.TickCount - Mobile.NextCombatTime < 0) + { + from.SendMessage("You can not transfer a pet while in combat."); + to.SendMessage("You can not transfer a pet while in combat."); + return PersistentOrder; + } + + var fromState = from.NetState; + var toState = to.NetState; + + if (fromState == null || toState == null) + { + return PersistentOrder; + } + + if (from.HasTrade || to.HasTrade) + { + from.SendLocalizedMessage(1010507); + // You cannot transfer a pet with a trade pending + to.SendLocalizedMessage(1010507); + // You cannot transfer a pet with a trade pending + return PersistentOrder; + } + + var container = fromState.AddTrade(toState); + container.DropItem(new TransferItem(Mobile)); + + // Hold position while the trade window is open. + Mobile.PlaySound(Mobile.GetIdleSound()); + Mobile.SetCurrentSpeedToPassive(); + SetPersistentOrder(OrderType.Stay); + return OrderType.Stay; + } + + private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage) + { + var args = $"{to.Name}\t{from.Name}\t "; + + from.SendLocalizedMessage(fromMessage, args); + to.SendLocalizedMessage(toMessage, args); + } + + // SetControlMaster(null) assigns ControlOrder = None underneath; the funnel keeps that write. + private OrderType IssueRelease() + { + if (Mobile.Summoned) + { + Mobile.Kill(); + + // A vetoed death leaves the summon controlled; it keeps its standing order. + return Mobile.Deleted || !Mobile.Alive ? OrderType.None : PersistentOrder; + } + + DebugSay("I have been released to the wild."); + + if (!string.IsNullOrEmpty(Mobile.Name)) + { + Mobile.Name = null; + } + + Mobile.PlaySound(Mobile.GetIdleSound()); + + Mobile.ControlTarget = null; + Mobile.BondingBegin = DateTime.MinValue; + Mobile.OwnerAbandonTime = DateTime.MinValue; + Mobile.IsBonded = false; + // Nothing of the old master survives a re-tame. + Mobile.ClearPetFriends(); + PersistentOrder = OrderType.None; + _persistentTarget = null; + Mobile.SetControlMaster(null); + + var spawner = Mobile.Spawner; + + if (spawner != null) + { + Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); + Mobile.RangeHome = spawner.WalkingRange; + } + else + { + // No spawner: anchor here rather than path toward a stale stay anchor. + Mobile.Home = Mobile.Location; + Action = ActionType.Wander; + } + + if (Mobile.DeleteOnRelease || Mobile.IsDeadPet) + { + Mobile.Delete(); + } + else + { + Mobile.BeginDeleteTimer(); + + if (Mobile.CanDrop) + { + Mobile.DropBackpack(); + } + } + + return OrderType.None; + } + + protected virtual OrderType IssueRename(Mobile issuer, OrderType previous, Mobile interruptedTarget) + { + var to = issuer ?? Mobile.ControlMaster; + + if (Mobile.Summoned) + { + to?.SendMessage("You cannot rename a summoned creature."); + } + else + { + to?.SendMessage("Change name on pet health bar."); + } + + return ResumeInterrupted(previous, interruptedTarget); + } + + // Only restable orders arrive here; anything else is a pre-refactor save and resumes the standing order. + public virtual bool Obey() + { + if (Mobile.Deleted) + { + return false; + } + + switch (Mobile.ControlOrder) + { + case OrderType.None: + { + return DoOrderNone(); + } + case OrderType.Come: + { + return DoOrderCome(); + } + case OrderType.Guard: + { + return DoOrderGuard(); + } + case OrderType.Attack: + { + return DoOrderAttack(); + } + case OrderType.Stay: + { + return DoOrderStay(); + } + case OrderType.Follow: + { + return DoOrderFollow(); + } + default: + { + ResumePersistentOrder(); + return true; + } + } + } public virtual bool DoOrderNone() { @@ -81,8 +600,7 @@ public abstract partial class BaseAI Mobile.Warmode = IsValidCombatant(Mobile.Combatant); - // Pure idle: gently wander near the anchor, with CheckIdle rest periods. Pets resume - // a standing order via ResumePersistentOrder, not by re-deriving it here. + // A standing order is resumed through ResumePersistentOrder, never re-derived here. WalkRandomIdle(); return true; } @@ -144,166 +662,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, 1, 2); } } - public virtual bool DoOrderDrop() - { - if (Mobile.IsDeadPet || !Mobile.CanDrop) - { - return true; - } - - this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); - - DropItems(); - ResumePersistentOrder(); - return true; - } - - private void DropItems() - { - var pack = Mobile.Backpack; - - if (pack == null) - { - return; - } - - var items = pack.Items; - - for (var i = items.Count - 1; i >= 0; --i) - { - if (i < items.Count) - { - items[i].MoveToWorld(Mobile.Location, Mobile.Map); - } - } - } - - public virtual bool DoOrderFriend() - { - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - HandleFriendRequest(from, to); - return true; - } - - private void HandleFriendRequest(Mobile from, Mobile to) - { - var youngFrom = from is PlayerMobile mobile && mobile.Young; - var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; - - if (youngFrom && !youngTo) - { - from.SendLocalizedMessage(502040); - // As a young player, you may not friend pets to older players. - return; - } - - if (!youngFrom && youngTo) - { - from.SendLocalizedMessage(502041); - // As an older player, you may not friend pets to young players. - return; - } - - if (!from.CanBeBeneficial(to, true)) - { - return; - } - - if (to?.Deleted != false || from == to || !to.Player) - { - Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); - // *looks confused* - return; - } - - if (from.HasTrade || to.HasTrade) - { - (from.HasTrade ? from : to).SendLocalizedMessage(1070947); - // You cannot friend a pet with a trade pending - return; - } - - if (Mobile.IsPetFriend(to)) - { - from.SendLocalizedMessage(1049691); - // That person is already a friend. - ResumePersistentOrder(); - return; - } - - if (!Mobile.AllowNewPetFriend) - { - from.SendLocalizedMessage(1005482); - // Your pet does not seem to be interested in making new friends right now. - return; - } - - from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}"); - // ~1_NAME~ will now accept movement commands from ~2_NAME~. - - to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}"); - // ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~. - // This creature will now consider you as a friend. - - Mobile.AddPetFriend(to); - - Mobile.ControlTarget = to; - Mobile.ControlOrder = OrderType.Follow; - } - - public virtual bool DoOrderUnfriend() - { - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - HandleUnfriendRequest(from, to); - return true; - } - - private void HandleUnfriendRequest(Mobile from, Mobile to) - { - if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) - { - Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); - // *looks confused* - return; - } - - if (!Mobile.IsPetFriend(to)) - { - from.SendLocalizedMessage(1070953); - // That person is not a friend. - ResumePersistentOrder(); - return; - } - - from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}"); - // ~1_NAME~ will no longer accept movement commands from ~2_NAME~. - - to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}"); - // ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~. - // This creature will no longer consider you as a friend. - - Mobile.RemovePetFriend(to); - - Mobile.ControlTarget = from; - Mobile.ControlOrder = OrderType.Follow; - } - public virtual bool DoOrderGuard() { var controlMaster = Mobile.ControlMaster; @@ -337,23 +701,15 @@ public abstract partial class BaseAI var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); - if (distance > 3) - { - // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. - if (Core.AOS) - { - Mobile.CurrentSpeed = 0.1; - } - else - { - Mobile.SetCurrentSpeedToActive(); - } + // Alert either way; FollowMoveSpeed caps the steps of the return itself. + Mobile.SetCurrentSpeedToActive(); - WalkMobileRange(controlMaster, 1, 1, 3); + if (distance > GuardRange) + { + WalkMobileRange(controlMaster, 1, 1, GuardRange); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -374,10 +730,14 @@ public abstract partial class BaseAI } else { - Mobile.Combatant = Mobile.ControlTarget; - this.DebugSayFormatted($"Attacking target: {Mobile.ControlTarget?.Name}"); + // OnAggressiveAction can swap Combatant; the commanded target wins. + if (Mobile.Combatant != Mobile.ControlTarget) + { + Mobile.Combatant = Mobile.ControlTarget; + } + Think(); } @@ -391,7 +751,6 @@ public abstract partial class BaseAI { DebugSay("Target is either dead, hidden, or out of range."); - Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. @@ -405,9 +764,7 @@ public abstract partial class BaseAI if (next != null) { - Mobile.ControlTarget = next; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = next; + Mobile.IssueOrder(OrderType.Attack, null, next); this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); @@ -473,55 +830,6 @@ public abstract partial class BaseAI return best; } - /// - /// The whole release: the master is cleared here, so this runs once, synchronously, from - /// the Release order handler or the loyalty drain, never from Obey. - /// - public virtual bool DoOrderRelease() - { - DebugSay("I have been released to the wild."); - - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.BondingBegin = DateTime.MinValue; - Mobile.OwnerAbandonTime = DateTime.MinValue; - Mobile.IsBonded = false; - Mobile.SetControlMaster(null); - - var spawner = Mobile.Spawner; - - if (spawner != null) - { - Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); - Mobile.RangeHome = spawner.WalkingRange; - } - else - { - // No spawner to return to: anchor where it stands so it idle-wanders here - // instead of pathing toward a stale (e.g. former stay) anchor. - Mobile.Home = Mobile.Location; - Action = ActionType.Wander; - } - - if (Mobile.DeleteOnRelease || Mobile.IsDeadPet) - { - Mobile.Delete(); - } - else - { - Mobile.BeginDeleteTimer(); - - if (Mobile.CanDrop) - { - Mobile.DropBackpack(); - } - } - - return true; - } - public virtual bool DoOrderStay() { if (CheckHerding()) @@ -542,103 +850,4 @@ public abstract partial class BaseAI return true; } - - // Stop is resolved into another order in OnCurrentOrderChanged and never rests as the - // active order; this is a defensive no-op. - public virtual bool DoOrderStop() => true; - - public virtual bool DoOrderTransfer() - { - if (Mobile.IsDeadPet) - { - return true; - } - - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - if (from?.Deleted == false && to?.Deleted == false && from != to && to.Player) - { - this.DebugSayFormatted($"Beginning transfer with {to.Name}"); - - var youngFrom = from is PlayerMobile mobile && mobile.Young; - var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; - - if (youngFrom && !youngTo) - { - from.SendLocalizedMessage(502040); - // As a young player, you may not friend pets to older players. - ResumePersistentOrder(); - return true; - } - - if (!youngFrom && youngTo) - { - from.SendLocalizedMessage(502041); - // As an older player, you may not friend pets to young players. - ResumePersistentOrder(); - return true; - } - - if (!Mobile.CanBeControlledBy(to)) - { - SendTransferRefusalMessages(from, to, 1043248, 1043249); - // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ - // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ - ResumePersistentOrder(); - return true; - } - - if (!Mobile.CanBeControlledBy(from)) - { - SendTransferRefusalMessages(from, to, 1043250, 1043251); - // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ - // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ - ResumePersistentOrder(); - return true; - } - - if (Mobile.Combatant != null || Mobile.Aggressors.Count > 0 || - Mobile.Aggressed.Count > 0 || Core.TickCount < Mobile.NextCombatTime) - { - from.SendMessage("You can not transfer a pet while in combat."); - to.SendMessage("You can not transfer a pet while in combat."); - ResumePersistentOrder(); - return true; - } - - var fromState = from.NetState; - var toState = to.NetState; - - if (fromState == null || toState == null) - { - ResumePersistentOrder(); - return true; - } - - if (from.HasTrade || to.HasTrade) - { - from.SendLocalizedMessage(1010507); - // You cannot transfer a pet with a trade pending - to.SendLocalizedMessage(1010507); - // You cannot transfer a pet with a trade pending - ResumePersistentOrder(); - return true; - } - - var container = fromState.AddTrade(toState); - container.DropItem(new TransferItem(Mobile)); - } - - Mobile.ControlOrder = OrderType.Stay; - return true; - } - - private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage) - { - var args = $"{to.Name}\t{from.Name}\t "; - - from.SendLocalizedMessage(fromMessage, args); - to.SendLocalizedMessage(toMessage, args); - } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 331df533c..7eac17ae5 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -161,12 +161,11 @@ internal sealed partial class TransferItem : Item _creature.SummonMaster = to; } - _creature.ControlTarget = to; - _creature.ControlOrder = OrderType.Follow; + _creature.ClearPetFriends(); + _creature.IssueOrder(OrderType.Follow, null, to); _creature.BondingBegin = DateTime.MinValue; _creature.OwnerAbandonTime = DateTime.MinValue; _creature.IsBonded = false; - _creature.PlaySound(_creature.GetIdleSound()); var args = $"{from.Name}\t{_creature.Name}\t{to.Name}"; from.SendLocalizedMessage(1043253, args); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f8dba7bf1..b7e893f84 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1059,9 +1059,6 @@ namespace Server.Mobiles public virtual bool CanDestroyObstacles => false; - // OSI followers were distracted by attacks well into AoS; removed around ML. - public virtual bool CanBeDistracted => !Core.ML; - public override bool ShouldCheckStatTimers => false; public virtual bool CanAngerOnTame => false; @@ -1103,6 +1100,13 @@ namespace Server.Mobiles // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). private const double HerdingMoveSpeed = 0.3; + /// + /// Seconds per step while closing on the master under a standing order. A cap, not an + /// override: a creature configured faster keeps its own pace. 0 disables it. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual double FollowMoveSpeed => Core.AOS ? 0.1 : 0; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { @@ -1112,9 +1116,9 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) - /// stays fused to both clocks. A herded creature is always driven at - /// . + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. A + /// herded creature is always driven at , and a pet + /// closing on its master is capped at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -1126,17 +1130,26 @@ namespace Server.Mobiles return HerdingMoveSpeed; } + double speed; + if (_currentSpeed == _activeSpeed) { - return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; + speed = _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; } - - if (_currentSpeed == _passiveSpeed) + else if (_currentSpeed == _passiveSpeed) { - return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + speed = _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + } + else + { + speed = _currentSpeed; } - return _currentSpeed; + var followSpeed = FollowMoveSpeed; + + return followSpeed > 0 && AIObject?.IsPacingToMaster() == true + ? Math.Min(followSpeed, speed) + : speed; } } @@ -1188,26 +1201,74 @@ namespace Server.Mobiles } } - // 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. + // Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor). + // A raw assignment is system-issued; player commands go through IssueOrder. [SerializableProperty(18, useField: nameof(_controlOrder))] [SaveFlag(nameof(ShouldSerializeControlOrder))] [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { get => _controlOrder; - set + set => SetControlOrder(value, null, false); + } + + /// + /// Gives this pet a command. (null = system-issued) is the only mobile + /// revealed; replaces first. + /// + public void IssueOrder(OrderType order, Mobile issuer, Mobile target = null) + { + // The interrupted order owns this; BaseAI.ResumeInterrupted hands it back. + var interrupted = ControlTarget; + ControlTarget = target; + SetControlOrder(order, issuer, false, interrupted); + } + + // Loops until the Issue phase returns an order that rests. `resuming` = falling back to + // the standing order: no re-derivation, no flourish. + internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming) => + SetControlOrder(order, issuer, resuming, ControlTarget); + + internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming, Mobile interruptedTarget) + { + var ai = AIObject; + var previous = _controlOrder; + _controlOrder = order; + + if (ai != null) { - var previous = _controlOrder; - _controlOrder = value; + for (var depth = 0; ; depth++) + { + var next = ai.IssueOrder(order, previous, issuer, resuming, interruptedTarget); - AIObject?.OnCurrentOrderChanged(previous); + // A nested assignment (SetControlMaster(null), Kill()) already resolved itself; it wins. + if (_controlOrder != order || next == order) + { + break; + } - InvalidateProperties(); + System.Diagnostics.Debug.Assert(depth < 8, "pet order resolution did not converge"); - _controlMaster?.InvalidateProperties(); - this.MarkDirty(); + if (depth >= 8) + { + // Non-converging override: rest at the standing order. + _controlOrder = ai.PersistentOrder; + break; + } + + previous = order; + order = next; + issuer = null; // chained resolutions reveal nobody + resuming = true; + _controlOrder = order; + } + + System.Diagnostics.Debug.Assert(Deleted || BaseAI.IsRestableOrder(_controlOrder), "a transient pet order rested"); } + + InvalidateProperties(); + _controlMaster?.InvalidateProperties(); + this.MarkDirty(); } [CommandProperty(AccessLevel.GameMaster)] @@ -1322,6 +1383,15 @@ namespace Server.Mobiles public static bool BondingEnabled { get; private set; } + /// + /// Publish 51: a pet told to follow, come, stay or stop "will not attack anything, even + /// if it is attacked". Guard and attack are unaffected. The publish has no step of its own + /// on the expansion ladder, so it rides ML and the setting carries the rest. + /// + public static bool PetsStandDownOnCommand { get; private set; } + + public virtual bool StandsDownOnCommand => PetsStandDownOnCommand; + public virtual bool IsBondable => BondingEnabled && !Summoned; public virtual TimeSpan BondingDelay => TimeSpan.FromDays(7.0); public virtual TimeSpan BondingAbandonDelay => TimeSpan.FromDays(1.0); @@ -1875,17 +1945,6 @@ namespace Server.Mobiles BardPacified = false; } - public virtual void CheckDistracted(Mobile from) - { - if (Utility.RandomDouble() < .10) - { - ControlTarget = from; - ControlOrder = OrderType.Attack; - Combatant = from; - Warmode = true; - } - } - public override void OnDamage(int amount, Mobile from, bool willKill) { if (BardPacified && (HitsMax - Hits) * 0.001 > Utility.RandomDouble()) @@ -1927,21 +1986,11 @@ namespace Server.Mobiles ReceivedHonorContext?.OnTargetDamaged(from, amount); - if (!willKill && CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - base.OnDamage(amount, from, willKill); } public virtual void OnDamagedBySpell(Mobile from, int damage) { - if (CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - TriggerAbility(MonsterAbilityTrigger.TakeSpellDamage, from); } @@ -2688,7 +2737,7 @@ namespace Server.Mobiles if (AIObject != null) { - if (!Core.ML || ct != OrderType.Follow && ct != OrderType.Stop && ct != OrderType.Stay) + if (!StandsDownOnCommand || !BaseAI.IsStandDownOrder(ct)) { AIObject.OnAggressiveAction(aggressor); } @@ -2714,11 +2763,10 @@ namespace Server.Mobiles } } - if (aggressor.ChangingCombatant && (_controlled || _summoned) && - (ct == OrderType.Come || !Core.ML && ct == OrderType.Stay || ct is OrderType.Stop or OrderType.None or OrderType.Follow)) + // Only reachable when the pet does not stand down: the orders above returned early. + if (aggressor.ChangingCombatant && (_controlled || _summoned) && BaseAI.IsStandDownOrder(ct)) { - ControlTarget = aggressor; - ControlOrder = OrderType.Attack; + IssueOrder(OrderType.Attack, null, aggressor); } else if (Combatant == null && !BardPacified) { @@ -3419,8 +3467,7 @@ namespace Server.Mobiles Mana = 0; IsDeadPet = true; - ControlTarget = ControlMaster; - ControlOrder = OrderType.Follow; + IssueOrder(OrderType.Follow, null, ControlMaster); ProcessDelta(); SendIncomingPacket(); @@ -4159,6 +4206,7 @@ namespace Server.Mobiles public static void Configure() { BondingEnabled = ServerConfiguration.GetSetting("taming.enableBonding", Core.LBR); + PetsStandDownOnCommand = ServerConfiguration.GetSetting("taming.petsStandDownOnCommand", Core.ML); } public void BeginDeleteTimer() @@ -4239,12 +4287,20 @@ namespace Server.Mobiles public virtual void AddPetFriend(Mobile m) { - Friends ??= new List(); - + Friends ??= []; Friends.Add(m); + this.MarkDirty(); } - public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m); + public virtual void RemovePetFriend(Mobile m) + { + if (Friends?.Remove(m) == true) + { + this.MarkDirty(); + } + } + + public virtual void ClearPetFriends() => Friends = null; // generated setter marks dirty public virtual bool IsFriend(Mobile m) => OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team @@ -5923,7 +5979,7 @@ namespace Server.Mobiles c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! c.Loyalty = BaseCreature.MaxLoyalty; - c.AIObject.DoOrderRelease(); + c.ControlOrder = OrderType.Release; } while (toRemove.Count > 0) diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs index 7706115f5..ff40211e8 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs @@ -93,8 +93,6 @@ namespace Server.Mobiles public override FoodType FavoriteFood => FoodType.None; - public override bool CanBeDistracted => false; - public override string DefaultName => "a golem"; public override bool DeleteOnRelease => true; diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs index 0d63142f2..9c174f07a 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs @@ -86,9 +86,7 @@ namespace Server.Mobiles if (!Core.SE && Utility.RandomDouble() < 0.2 && attacker is BaseCreature c && c.Controlled && c.ControlMaster != null) { - c.ControlTarget = c.ControlMaster; - c.ControlOrder = OrderType.Attack; - c.Combatant = c.ControlMaster; + c.IssueOrder(OrderType.Attack, null, c.ControlMaster); } } } diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 6e0902b64..55aa32605 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,7 +27,8 @@ description: > (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is + AND clears move overrides, `SetMoveSpeed()` sets move only. Herding and pacing to a + master (`FollowMoveSpeed`) cap the resolved pace without writing either clock. The client `Running` bit is derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index f1df32d42..d2d301306 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -284,6 +284,17 @@ 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). +Two conditions cap the resolved step pace without touching either clock, so nothing is +stored and nothing needs undoing when the condition ends: + +- **Herding** — a creature with a `TargetLocation` is driven at a fixed `HerdingMoveSpeed`. +- **Pacing to the master** — a pet following its master, or guarding from outside guard + range, is capped at `FollowMoveSpeed` (AOS 0.1, earlier eras 0; RunUO's pet sprint). It is + a cap, not an override: a creature configured faster keeps its own pace, and its + `ActiveMoveSpeed`/`PassiveMoveSpeed` are left untouched. Override the virtual to change + the pace or to enable it in an era that has it off. Decisions are unaffected — a following + pet thinks on its active clock. + 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` / From c9831b9680aaface8172682383966aa13bfc4738 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:36:30 -0700 Subject: [PATCH 14/21] fix: TextDefinition was uneditable in the props gump, and `where` parsed constants its own way (#2624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Pressing `>` on a TextDefinition did nothing useful `#1217` moved `TextDefinition` into the Server project for serialization support and added `[PropertyObject]` along the way. `PropsGump` checks that attribute **before** its parsable fallback, so from that commit on the button either drilled into a read-only `Number`/`String` page (both get-only since `#1221`, so no `>` buttons — a dead end) or, when the value was null, re-sent the same page and looked inert. Worth being clear that `#765` — which replaced the hand-maintained type list with a generic `IsParsable` branch — was **not** the regression. At that commit `TextDefinition` was `[Parsable]` only, fell through to the new branch, and the editor worked. Only the later attribute hijacked the routing. `TextDefinition` is now caught ahead of the `[PropertyObject]` branch and routed to `SetGump`, which has had `TextDefinition`-specific code at line 35 all along. ## `0` and `"0"` could not be told apart `Commands.Split` strips quotes before any parser runs, so this was never fixable in `TextDefinition.Parse` alone — the information is gone two layers earlier. The markers now travel inside the value: | Input | Result | |---|---| | `1060847` | cliloc (unchanged) | | `#1060847` | cliloc, explicit — the form `ToString()` already writes | | `@"1060847"` | the literal string | | `hello` | string (unchanged) | | `(-null-)` | null (unchanged) | `GetValue()` quotes a string that would not survive the trip back. That check is a real round trip through the parser rather than a pattern match, so it cannot drift from it. `Types.TryParse` decodes the same escape for plain strings — which is what `[get` has always written for the literal `"null"` without `[set` ever reading it back. **Saved data is untouched.** Serialization reads a discriminated flag plus int/string (`IGenericReader.cs:175`) and the JSON converter switches on token type; neither calls `Parse`, so a stored string `"1060847"` stays a string and spawner JSON is unchanged. The new syntax stays in the text and command layer, where it reaches `[set`, `[add`, spawner props, the props gump and Advanced Search. ## `where` still parsed its constants its own way `#2625` carried the hand-rolled constant parser across to `PropertyExpressions.Parse` unchanged. It looks for a static `Parse` overload on the property type and gives up if there is none — and `Type` and `IEntity` have neither: ``` where Subject Kind = Static -> Unable to convert string "Static" into type 'System.Type'. where Subject Owner = 0x1 -> Unable to convert string "0x1" into type 'Server.Mobile'. ``` Both resolve fine for every other command. Routing through `Types.TryParse` picks up type-name lookup, entity resolution by serial, and the `@"..."` literal convention, so a value that works in one place now works everywhere. It also deletes the duplicate parser (−56 lines in `PropertyExpressions`). Two things are load-bearing and preserved. `where` has always spelled a null constant as a bare `null`, where `[set` uses `(-null-)`; the shared parser reads a bare `null` as the text, so the null case is handled ahead of it and `= null` keeps meaning null. And nullable targets still unwrap first, so `where = 5` is unaffected. Bare and hex integers, enums, bools, strings, `Map`, and TextDefinition's `#` / `@"..."` markers all resolve exactly as before — 13 of the 15 tests in `WhereConstantParsingTests` pin that and passed before the change as well as after. ## Documentation The generic command system had no documentation anywhere in the repo: scopes, `where` and its operators, dot notation, `order by` / `distinct` / `limit`, the value and quoting syntax, `[interface` and `[batch` were all learn-by-reading-the-source. `dev-docs/generic-commands.md` covers them, linked from `CLAUDE.md` and `commands-targeting.md`, and points at for the per-command list rather than duplicating it. Two behaviours it records that were news to me while writing it: - `Contained` honours conditions on the normal command path but never sets `SupportsConditionals`, and `[batch` is the only place that reads the flag — so the same condition works typed directly and is refused under batch. - `Multi`, `Single`, `Self` and `Serial` do not parse modifiers at all, so a `where` clause there is passed to the command as ordinary arguments rather than rejected. Comments in the changed code were trimmed to what is not evident from the code itself; the rationale they carried is either in the doc now or in the commit that introduced it. ## Behavior changes worth a reviewer's attention Two things go beyond strict bug-fixing, both deliberate: - **Hex now parses into a TextDefinition.** Consolidating the three `Parse` overloads onto one span codec means `[set Message 0x102CE7` is cliloc 1060847 rather than the string. Previously only the 1-arg `Parse(string)` did hex and nothing called it. This makes the props gump's own `1060847 (0x102CE7)` display typeable. - **`@"..."` decodes generally for strings**, not only the exact token `@"null"`. So `[set Name @"hello"` sets `hello`. A half-working escape seemed more surprising than a general one, and the `where` path already decoded it — but narrowing it back is a one-line change if preferred. ## Testing 40 tests, each written first and watched fail for the right reason. They cover the props gump routing (populated and null), cliloc/string/quoted parsing and `GetValue` round trips, `where` constant resolution for Type- and entity-valued properties, and the bare-`null` vs `@"null"` distinction that must not drift. One sort case is added that #2625 left uncovered: ordering a value-typed chain whose intermediate is null, which reads as `default(int)` rather than throwing. Two other tests from the pre-rebase branch were dropped as duplicates of `ConditionalCompilerEdgeTests`. Rebased onto `535a09899`. `dotnet build` clean, 0 warnings. **902 UOContent** and **869 Server** tests pass, 0 failures. ## Known gaps - `Nullable` comparisons work via #2625's lifted operators; nothing here changes that. - Spawner **Params** are split on plain spaces (`BaseSpawner.cs:1035`) rather than through `Commands.Split`, so a constructor argument still cannot contain one. Untouched here — a tokenizer issue, not a TextDefinition one. - `Types.ParseStringNumericParamTypes` is now unreferenced. Left in place because it is public and shards may use it. --- CLAUDE.md | 1 + Projects/Server/Text/TextDefinition.cs | 66 ++++- .../Tests/Commands/ChainedBindingSortTests.cs | 14 + .../Commands/TextDefinitionCommandTests.cs | 139 ++++++++++ .../Commands/WhereConstantParsingTests.cs | 160 ++++++++++++ .../Gumps/PropsGumpTextDefinitionTests.cs | 202 +++++++++++++++ .../Compilers/PropertyExpressions.cs | 60 +---- Projects/UOContent/Gumps/Props/PropsGump.cs | 6 + Projects/UOContent/Utilities/Types.cs | 32 ++- dev-docs/commands-targeting.md | 6 + dev-docs/generic-commands.md | 239 ++++++++++++++++++ 11 files changed, 855 insertions(+), 70 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs create mode 100644 dev-docs/generic-commands.md diff --git a/CLAUDE.md b/CLAUDE.md index c77d39e19..ba7b3f5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Object property lists (tooltips) | `dev-docs/property-lists.md` | | Gump (UI dialog) system | `dev-docs/gump-system.md` | | Commands & targeting | `dev-docs/commands-targeting.md` | +| Generic commands (`where`/`order by`/`distinct`, dot notation, `@""` literals, `[batch`, `[interface`) | `dev-docs/generic-commands.md` | | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | | Server hardware requirements | `dev-docs/server-requirements.md` | diff --git a/Projects/Server/Text/TextDefinition.cs b/Projects/Server/Text/TextDefinition.cs index 4951c2290..0dfcc7ba8 100644 --- a/Projects/Server/Text/TextDefinition.cs +++ b/Projects/Server/Text/TextDefinition.cs @@ -68,7 +68,26 @@ public class TextDefinition : IEquatable, IEquatable, IS Number > 0 ? $"{Number} (0x{Number:X})" : String != null ? $"\"{String}\"" : null; - public string GetValue() => Number > 0 ? Number.ToString() : String ?? ""; + /// + /// The editable text form. Quotes a string that TryParse would not read back unchanged; + /// the check is a real round trip so it cannot drift from the parser. + /// + public string GetValue() + { + if (Number > 0) + { + return Number.ToString(); + } + + if (String == null) + { + return ""; + } + + return TryParse(String, null, out var parsed) && parsed.Number == 0 && parsed.String == String + ? String + : $"@\"{String}\""; + } public static implicit operator TextDefinition(int v) => Of(v); @@ -120,15 +139,7 @@ public class TextDefinition : IEquatable, IEquatable, IS public static bool operator !=(TextDefinition left, TextDefinition right) => !Equals(left, right); - public static TextDefinition Parse(string value) - { - if (value == null) - { - return null; - } - - return Utility.ToInt32(value, out var i) ? Of(i) : Of(value); - } + public static TextDefinition Parse(string value) => value == null ? null : Parse(value.AsSpan(), null); public static TextDefinition Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); @@ -137,13 +148,30 @@ public class TextDefinition : IEquatable, IEquatable, IS public static TextDefinition Parse(ReadOnlySpan s, IFormatProvider provider) { - // We don't trim - return int.TryParse(s, provider, out var label) ? Of(label) : Of(s); + TryParse(s, provider, out var result); + return result; } + /// + /// #1234 or a bare 1234/0x4D2 is a cliloc; @"1234" is the literal + /// text. Always succeeds -- anything that is not a cliloc is a string. + /// See dev-docs/generic-commands.md. + /// public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out TextDefinition result) { - if (int.TryParse(s, provider, out var label)) + if (TryGetQuotedLiteral(s, out var literal)) + { + result = Of(literal); + return true; + } + + if (s.Length > 1 && s[0] == '#' && Utility.ToInt32(s[1..], out var marked)) + { + result = Of(marked); + return true; + } + + if (Utility.ToInt32(s, out var label)) { result = Of(label); return true; @@ -153,4 +181,16 @@ public class TextDefinition : IEquatable, IEquatable, IS result = Of(s); return true; } + + private static bool TryGetQuotedLiteral(ReadOnlySpan s, out ReadOnlySpan literal) + { + if (s.Length >= 3 && s[0] == '@' && s[1] == '"' && s[^1] == '"') + { + literal = s[2..^1]; + return true; + } + + literal = default; + return false; + } } diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs index 6283dd99a..a7c266589 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -94,6 +94,20 @@ public class ChainedBindingSortTests : IDisposable Assert.Equal(0, comparer.Compare(blank, alsoBlank)); } + // A null intermediate on a value-typed chain reads as default(int) rather than throwing. + [Fact] + public void SortingOnAValueTypeChainStillSurvivesANullIntermediate() + { + var valued = Teleporter(TextDefinition.Of(1000)); + var blank = Teleporter(null); + + var comparer = Sorter("Message.Number"); + + // A null Message reads as default(int) == 0, so it sorts below cliloc 1000. + Assert.True(comparer.Compare(blank, valued) < 0); + Assert.True(comparer.Compare(valued, blank) > 0); + } + // An unchained binding never had the problem and must keep working untouched. [Fact] public void SortingOnAPlainBindingIsUnchanged() diff --git a/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs b/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs new file mode 100644 index 000000000..f8502e193 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs @@ -0,0 +1,139 @@ +using Server; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// Commands.Split strips quotes before any parser runs, so "0" and 0 arrive identical -- the +// markers are the only way to say which was meant. See dev-docs/generic-commands.md. +public class TextDefinitionParsingTests +{ + private static TextDefinition Parse(string value) + { + Assert.Null(Types.TryParse(typeof(TextDefinition), value, out var parsed)); + return parsed as TextDefinition; + } + + [Theory] + [InlineData("1060847", 1060847)] + [InlineData("#1060847", 1060847)] + public void ClilocsParseToNumber(string entered, int expected) + { + var td = Parse(entered); + + Assert.NotNull(td); + Assert.Equal(expected, td.Number); + Assert.Null(td.String); + } + + // ToString() already emits '#', so this closes the round trip. + [Fact] + public void ClilocMarkerRoundTripsThroughToString() + { + var td = TextDefinition.Of(1060847); + + Assert.Equal("#1060847", td.ToString()); + Assert.Equal(td, Parse(td.ToString())); + } + + [Theory] + [InlineData("hello", "hello")] + [InlineData(@"@""0""", "0")] + [InlineData(@"@""1060847""", "1060847")] + [InlineData(@"@""null""", "null")] + [InlineData(@"@""#5""", "#5")] + public void QuotedLiteralsForceAString(string entered, string expected) + { + var td = Parse(entered); + + Assert.NotNull(td); + Assert.Equal(0, td.Number); + Assert.Equal(expected, td.String); + } + + // Bare integers stay clilocs, so existing content is unaffected. + [Fact] + public void BareZeroStaysAnEmptyCliloc() + { + var td = Parse("0"); + + Assert.NotNull(td); + Assert.Equal(0, td.Number); + Assert.Null(td.String); + } + + // GetValue() fills the props-gump edit box; an ambiguous string must come out quoted. + [Theory] + [InlineData("1060847")] + [InlineData("0")] + [InlineData("#5")] + public void AmbiguousStringsAreEmittedQuotedSoTheyReadBack(string text) + { + var round = Parse(TextDefinition.Of(text).GetValue()); + + Assert.NotNull(round); + Assert.Equal(0, round.Number); + Assert.Equal(text, round.String); + } + + [Fact] + public void UnambiguousValuesAreEmittedPlain() + { + Assert.Equal("Hail, traveller.", TextDefinition.Of("Hail, traveller.").GetValue()); + Assert.Equal("1060847", TextDefinition.Of(1060847).GetValue()); + } + + [Fact] + public void NullSentinelClearsTheValue() + { + Assert.Null(Types.TryParse(typeof(TextDefinition), "(-null-)", out var parsed)); + Assert.Null(parsed); + } +} + +// [get emits @"null" for the literal string "null"; [set has to decode it to round trip. +[Collection("Sequential UOContent Tests")] +public class StringEscapeRoundTripTests +{ + [Fact] + public void QuotedNullSetsTheLiteralStringNull() + { + Assert.Null(Types.TryParse(typeof(string), @"@""null""", out var parsed)); + + Assert.Equal("null", parsed); + } + + [Fact] + public void BareNullSentinelStillClearsTheValue() + { + Assert.Null(Types.TryParse(typeof(string), "(-null-)", out var parsed)); + + Assert.Null(parsed); + } + + [Fact] + public void GetOutputPastesBackIntoSet() + { + var item = new Static(0x1F13) { Name = "null" }; + + try + { + var from = new Mobile { AccessLevel = AccessLevel.Developer }; + var shown = Properties.GetValue(from, item, "Name"); + + // "Name = @"null"" -> the value half is what a GM copies. + var value = shown[(shown.IndexOf('=') + 2)..]; + Assert.Equal(@"@""null""", value); + + item.Name = "changed"; + Properties.SetValue(from, item, "Name", value); + + Assert.Equal("null", item.Name); + } + finally + { + item.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs b/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs new file mode 100644 index 000000000..34d0ca482 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// `where` constants resolve through Types.TryParse like every other command. Type- and +// entity-valued properties have no static Parse, so the old local parser threw on them. +[Collection("Sequential UOContent Tests")] +public class WhereConstantParsingTests : IDisposable +{ + private readonly List _entities = []; + + public void Dispose() + { + for (var i = 0; i < _entities.Count; i++) + { + _entities[i].Delete(); + } + + _entities.Clear(); + } + + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public Type Kind { get; set; } = typeof(Static); + + [CommandProperty(AccessLevel.GameMaster)] + public Mobile Owner { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public Item Thing { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public string Label { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public TextDefinition Message { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public Map Facet { get; set; } = Map.Felucca; + + [CommandProperty(AccessLevel.GameMaster)] + public int Count { get; set; } = 0x1F13; + + [CommandProperty(AccessLevel.GameMaster)] + public SkillName Craft { get; set; } = SkillName.Magery; + } + + private static bool Check(object target, string binding, string value, + ComparisonOperator op = ComparisonOperator.Equal) + { + var prop = new Property(binding); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var compiled = ConditionalCompiler.Compile( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(prop, false, op, value)] + ); + + return compiled.Verify(target); + } + + private T Track(T entity) where T : IEntity + { + _entities.Add(entity); + return entity; + } + + [Fact] + public void TypeValuedPropertiesResolveByName() + { + var s = new Subject(); + + Assert.True(Check(s, "Kind", "Static")); + Assert.False(Check(s, "Kind", "Container")); + } + + [Fact] + public void EntityValuedPropertiesResolveBySerial() + { + var mob = Track(new Mobile()); + var item = Track(new Static(0x1F13)); + var s = new Subject { Owner = mob, Thing = item }; + + Assert.True(Check(s, "Owner", mob.Serial.ToString())); + Assert.True(Check(s, "Thing", item.Serial.ToString())); + Assert.False(Check(s, "Thing", (item.Serial + 1).ToString())); + } + + // `where` spells null as a bare `null`, not [set's (-null-); every existing clause relies on it. + [Fact] + public void BareNullStillMeansNull() + { + var s = new Subject(); + + Assert.True(Check(s, "Label", "null")); + Assert.True(Check(s, "Owner", "null")); + + s.Label = "set"; + s.Owner = Track(new Mobile()); + + Assert.False(Check(s, "Label", "null")); + Assert.False(Check(s, "Owner", "null")); + } + + [Fact] + public void QuotedNullStillMeansTheLiteralString() + { + var s = new Subject { Label = "null" }; + + Assert.True(Check(s, "Label", @"@""null""")); + Assert.False(Check(s, "Label", "null")); + } + + [Theory] + [InlineData("Facet", "Felucca", true)] + [InlineData("Facet", "Trammel", false)] + [InlineData("Count", "0x1F13", true)] + [InlineData("Count", "7955", true)] + [InlineData("Count", "0x1F14", false)] + [InlineData("Craft", "Magery", true)] + [InlineData("Craft", "Anatomy", false)] + public void ExistingConstantFormsKeepWorking(string binding, string value, bool expected) + { + Assert.Equal(expected, Check(new Subject(), binding, value)); + } + + [Theory] + [InlineData("#1060847", true)] + [InlineData("#1060848", false)] + public void TextDefinitionMarkersReachTheWhereClause(string value, bool expected) + { + var s = new Subject { Message = TextDefinition.Of(1060847) }; + + Assert.Equal(expected, Check(s, "Message", value)); + } + + [Fact] + public void QuotedLiteralsReachTextDefinitionsInWhereToo() + { + var s = new Subject { Message = TextDefinition.Of("1060847") }; + + Assert.True(Check(s, "Message", @"@""1060847""")); + Assert.False(Check(s, "Message", "1060847")); + } + + [Fact] + public void UnresolvableConstantsStillReportAnError() + { + Assert.Throws( + () => Check(new Subject(), "Kind", "NoSuchTypeAnywhere") + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs b/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs new file mode 100644 index 000000000..f6dac1546 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Server; +using Server.Gumps; +using Server.Items; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace UOContent.Tests.Gumps; + +// TextDefinition carries [PropertyObject], which the props gump checks before its parsable +// fallback -- so the row needs an explicit branch or it drills into a read-only dead end. +[Collection("Sequential UOContent Tests")] +public class PropsGumpTextDefinitionTests : IDisposable +{ + // Mirrors PropertiesGump.MaxEntriesPerPage, which is private. + private const int MaxEntriesPerPage = 15; + + private readonly List _items = []; + private readonly List _mobiles = []; + private readonly List _states = []; + + public void Dispose() + { + for (var i = 0; i < _mobiles.Count; i++) + { + _mobiles[i].NetState = null; + _mobiles[i].Delete(); + } + + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + for (var i = 0; i < _states.Count; i++) + { + _states[i].Dispose(); + } + + _mobiles.Clear(); + _items.Clear(); + _states.Clear(); + } + + private (Mobile From, NetState State) CreateStaff() + { + var ns = PacketTestUtilities.CreateTestNetState(); + _states.Add(ns); + + var from = new Mobile { AccessLevel = AccessLevel.GameMaster }; + from.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _mobiles.Add(from); + + from.NetState = ns; + ns.Mobile = from; + + return (from, ns); + } + + private SkillTeleporter CreateTeleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + tp.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + _items.Add(tp); + return tp; + } + + // Opens the props gump on whichever page holds `propName` and presses that row's gold '>'. + private static void PressPropertyButton(Mobile from, NetState ns, object o, string propName) + { + var probe = new TestPropsGump(from, o); + var list = probe.List; + + var index = -1; + for (var i = 0; i < list.Count; i++) + { + if (list[i] is PropertyInfo p && p.Name == propName) + { + index = i; + break; + } + } + + Assert.True(index >= 0, $"{o.GetType().Name} has no visible '{propName}' property row."); + + var page = index / MaxEntriesPerPage; + var buttonId = index - page * MaxEntriesPerPage + 3; + + var gump = new TestPropsGump(from, o, list, page); + gump.OnResponse(ns, EmptyInfo(buttonId)); + } + + private static RelayInfo EmptyInfo(int buttonId) => + new(buttonId, ReadOnlySpan.Empty, ReadOnlySpan.Empty, ReadOnlySpan.Empty, ReadOnlySpan.Empty); + + private static RelayInfo TextInfo(int buttonId, int entryId, string text) + { + var block = Encoding.BigEndianUnicode.GetBytes(text); + var ids = new[] { (ushort)entryId }; + var ranges = new[] { new Range(0, block.Length) }; + + return new RelayInfo(buttonId, ReadOnlySpan.Empty, ids, ranges, block); + } + + [Fact] + public void PressingSetOnAPopulatedTextDefinitionOpensTheEditor() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(TextDefinition.Of(1060847)); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + Assert.NotNull(ns.FindGump()); + } + + // With no object to drill into, the old path re-sent the same page and looked inert. + [Fact] + public void PressingSetOnANullTextDefinitionOpensTheEditor() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(null); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + Assert.NotNull(ns.FindGump()); + } + + // Numeric input always wins, so "0" is cliloc 0 (empty), never the string "0". + [Theory] + [InlineData("1060847", 1060847, null)] + [InlineData("0", 0, null)] + [InlineData("Hail, traveller.", 0, "Hail, traveller.")] + public void EditorRoundTripsClilocAndString(string entered, int expectedNumber, string expectedString) + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(null); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + var setGump = ns.FindGump(); + Assert.NotNull(setGump); + + setGump.OnResponse(ns, TextInfo(1, 0, entered)); + + Assert.NotNull(tp.Message); + Assert.Equal(expectedNumber, tp.Message.Number); + Assert.Equal(expectedString, tp.Message.String); + } + + // The edit box is seeded from GetValue(), so a cliloc-looking string must survive the trip. + [Fact] + public void EditorPreservesAStringThatLooksLikeACliloc() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(TextDefinition.Of("1060847")); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + var setGump = ns.FindGump(); + Assert.NotNull(setGump); + + setGump.OnResponse(ns, TextInfo(1, 0, tp.Message.GetValue())); + + Assert.NotNull(tp.Message); + Assert.Equal(0, tp.Message.Number); + Assert.Equal("1060847", tp.Message.String); + } + + [Fact] + public void EditorNullButtonClearsTheValue() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(TextDefinition.Of("Hail, traveller.")); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + var setGump = ns.FindGump(); + Assert.NotNull(setGump); + + setGump.OnResponse(ns, EmptyInfo(2)); + + Assert.Null(tp.Message); + } + + // Exposes the protected list/page plumbing so a test can aim at a specific property row. + private sealed class TestPropsGump : PropertiesGump + { + public TestPropsGump(Mobile m, object o) : base(m, o) + { + } + + public TestPropsGump(Mobile m, object o, List list, int page) : base(m, o, null, list, page) + { + } + + public List List => m_List; + } +} diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs index 68f30447c..0689d130a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs @@ -327,10 +327,8 @@ public static class PropertyExpressions } /// - /// The right-hand side of a condition as a typed constant. A string is parsed the way the - /// props gump would parse it: null for a reference or nullable type, @"null" - /// for the literal string, names for enums, hex with a 0x prefix for the numerics, - /// and the type's own static Parse for everything else. + /// The right-hand side of a condition as a typed constant, resolved by the same parser behind + /// [set and [add. See dev-docs/generic-commands.md. /// public static ConstantExpression Constant(Type type, object value) { @@ -346,62 +344,12 @@ public static class PropertyExpressions { var underlying = Nullable.GetUnderlyingType(type); + // `where` spells null as a bare `null`, not [set's (-null-), so it precedes the parser. if (text == "null" && (underlying != null || !type.IsValueType)) { return null; } - var target = underlying ?? type; - - if (target == typeof(string)) - { - return text == @"@""null""" ? "null" : text; - } - - if (target.IsEnum) - { - return Enum.Parse(target, text, true); - } - - if (target == typeof(bool)) - { - return bool.Parse(text); - } - - var parseNumber = target.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringNumericParamTypes, - null - ); - - if (parseNumber != null) - { - var style = NumberStyles.Integer; - - if (text.InsensitiveStartsWith("0x")) - { - style = NumberStyles.HexNumber; - text = text[2..]; - } - - return parseNumber.Invoke(null, [text, style]); - } - - var parseGeneral = target.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringParamTypes, - null - ); - - if (parseGeneral != null) - { - return parseGeneral.Invoke(null, [text, null]); - } - - throw new InvalidOperationException($"Unable to convert string \"{text}\" into type '{type}'."); + return Types.ParseOrThrow(underlying ?? type, text); } } diff --git a/Projects/UOContent/Gumps/Props/PropsGump.cs b/Projects/UOContent/Gumps/Props/PropsGump.cs index eda11e287..00fd2557d 100644 --- a/Projects/UOContent/Gumps/Props/PropsGump.cs +++ b/Projects/UOContent/Gumps/Props/PropsGump.cs @@ -305,6 +305,12 @@ namespace Server.Gumps from.SendGump(new PropertiesGump(from, mobile, m_Stack, m_List, m_Page)); from.SendGump(new SkillsGump(from, mobile)); } + // Must stay ahead of [PropertyObject]: TextDefinition carries that + // attribute, but Number and String are get-only so drilling in is a dead end. + else if (IsType(type, OfText)) + { + from.SendGump(new SetGump(prop, from, m_Object, this)); + } else if (HasAttribute(type, OfPropertyObject, true)) { from.SendGump( diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index ca5b9422d..184773e84 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -192,6 +192,35 @@ namespace Server return false; } + // @"..." is the literal text inside, for values the bare text would be read as something + // else. See dev-docs/generic-commands.md. + private static bool TryGetQuotedLiteral(string value, out string literal) + { + if (value?.Length >= 3 && value[0] == '@' && value[1] == '"' && value[^1] == '"') + { + literal = value[2..^1]; + return true; + } + + literal = null; + return false; + } + + /// + /// for callers with nowhere to put an error string. + /// + public static object ParseOrThrow(Type type, string value) + { + var error = TryParse(type, value, out var constructed); + + if (error != null) + { + throw new InvalidOperationException(error); + } + + return constructed; + } + // Do not use this in "Parse" methods, it may cause a stack overflow public static string TryParse(Type type, string value, out object constructed) { @@ -244,7 +273,8 @@ namespace Server if (IsType(type, OfString)) { - constructed = value; + // Decodes what InternalGetValue writes, so [get output pastes back into [set. + constructed = TryGetQuotedLiteral(value, out var literal) ? literal : value; return null; } diff --git a/dev-docs/commands-targeting.md b/dev-docs/commands-targeting.md index d4e96c793..48a28c8bf 100644 --- a/dev-docs/commands-targeting.md +++ b/dev-docs/commands-targeting.md @@ -339,3 +339,9 @@ public override void OnCast() | `Projects/Server/Targeting/TargetCancelType.cs` | Cancel types | | `Projects/Server/Targeting/LandTarget.cs` | Land target | | `Projects/Server/Targeting/StaticTarget.cs` | Static target | + +## See Also + +- `dev-docs/generic-commands.md` — the generic command system: scopes, `where` conditions, + `order by` / `distinct` / `limit`, dot notation, value and quoting syntax, `[interface`, `[batch`. +- — the full command list, regenerated with distro updates. diff --git a/dev-docs/generic-commands.md b/dev-docs/generic-commands.md new file mode 100644 index 000000000..54019faf9 --- /dev/null +++ b/dev-docs/generic-commands.md @@ -0,0 +1,239 @@ +# Generic Commands (finding and manipulating entities) + +How staff find a set of objects and run a command against all of them: scopes, `where` conditions, +`order by` / `distinct` / `limit`, dot notation, value syntax, `[interface` and `[batch`. + +This covers the **generic command system** only. For the full per-command list (`[add`, `[props`, +`[tele`, …) see the in-game commands page shipped with the distro, published at +. + +## The shape of a generic command + +``` +[ [command args] [where ] [distinct ] [order by ] [limit ] +``` + +- **scope** — which objects to consider (`Global`, `Area`, `Region`, …). +- **command** — what to do to each (`Delete`, `Props`, `Set`, `Count`, `Interface`, …). +- **modifiers** — optional filters applied to the found set before the command runs. + +``` +[global count where Item Movable = false +[area delete where Item ItemID = 0x1F13 +[region interface where Mobile Hits < 10 order by Hits limit 20 +``` + +Commands opt into which scopes they support and whether they act on Items, Mobiles or both, so not +every command works under every scope. A command that does not support the scope reports +*"That is either an invalid command name or one that does not support this modifier."* + +## Scopes + +| Scope | Usage | Conditions? | Selects | +|---|---|---|---| +| `Global` | `[global [condition]` | yes | every object in the world | +| `Area` (`Group`) | `[area [condition]` | yes | a bounding box you drag | +| `Screen` | `[screen [condition]` | yes | everything on your screen | +| `Range` | `[range [condition]` | yes | within `` tiles of you | +| `Region` | `[region [condition]` | yes | your current region | +| `Facet` | `[facet [condition]` | yes | your whole map | +| `Contained` | `[contained [condition]` | yes¹ | inside a targeted container | +| `Online` | `[online [condition]` | yes | connected players | +| `IPAddress` | `[ipaddress [condition]` | yes | accounts sharing a targeted player's IP | +| `Multi` (`m`) | `[m ` | no | several objects you target in turn | +| `Single` | `[single ` | no | one targeted object | +| `Self` | `[self ` | no | you | +| `Serial` | `[serial ` | no | one object by serial | + +`Multi`, `Single`, `Self` and `Serial` do not parse modifiers at all — a `where` clause there is +not rejected, it is passed through to the command as ordinary arguments. + +¹ `Contained` honours conditions on the normal command path, but it never sets the +`SupportsConditionals` flag, and `[batch` is the one place that checks it. So a condition works +under `[contained` typed directly and is refused under `[batch` with that scope. + +## `where` + +The **first token after `where` is a type name**, and it is required. It filters the set to objects +of that type (subclasses included) and fixes the type whose properties the rest of the clause reads. + +``` +[global count where Item -- every Item +[global count where BaseCreature Hits < 10 +``` + +Only properties marked `[CommandProperty]` are visible, and your access level must meet the +attribute's read level. + +### Operators + +| Operator | Meaning | +|---|---| +| `=`, `==`, `is` | equal | +| `!=` | not equal | +| `>`, `<`, `>=`, `<=` | relational (needs a comparable type) | +| `=~`, `~=`, `==~`, `~==`, `is~`, `~is` | equal, case-insensitive | +| `!=~`, `~!=` | not equal, case-insensitive | +| `starts`, `ends`, `contains` | substring tests | +| `starts~`, `ends~`, `contains~` | substring tests, case-insensitive | + +The `~` may lead or trail — `~contains` and `contains~` are the same operator. + +Relational operators on a type with no ordering (no `IComparable`) are rejected rather than +silently misbehaving. Equality on such a type compares **by value**, not by reference. + +### Combining conditions + +Conditions separated by whitespace are ANDed. `or` (or `||`) starts a new alternative group, and +`not` (or `!`) negates the single condition that follows it. + +``` +[global count where Item Movable = true Hue = 0 +[global count where Item Hue = 0 or Hue = 1 +[global count where Item not Movable = true +``` + +## Dot notation + +A binding may walk a chain of properties: + +``` +[global count where SkillTeleporter Message.Number = 1060847 +[global interface where BaseCreature ControlMaster.Name =~ bob +``` + +If a link partway along the chain is null, the object simply does not match — it is not an error, +and it does not stop the sweep. The same applies under `not`: an unreadable binding never matches. + +For `order by` and `distinct`, which have no "no match" to give, a null link reads as the property +type's default (`0`, `null`, …). + +Chains are **read-only**. `[set Message.Number 5` fails when the intermediate's members are +get-only, as `TextDefinition`'s are. + +## Values + +A comparison constant is resolved by the same parser behind `[set`, `[add`, spawner props and the +props gump, so a value that works in one place works in all of them. + +| Form | Means | +|---|---| +| `123`, `-4` | a number | +| `0x1F13` | a number, hex | +| `true` / `false` | a boolean | +| `Magery` | an enum member, case-insensitive | +| `Felucca` | a `Map` | +| `Static`, `BaseCreature` | a `Type`, by name | +| `0x40001234` | an entity, resolved by serial | +| `hello world` | a string — quote it in the command line if it contains spaces | +| `null` | null (in `where` only — see below) | +| `(-null-)` | null (in `[set` / `[add` / spawner props) | +| `@"text"` | the literal text inside, for values that would otherwise be read as something else | +| `#1234` | a `TextDefinition` cliloc, explicitly | + +### Quoting, and why `@"..."` exists + +The command tokenizer strips real quotes before any parser sees them, so `"0"` and `0` arrive +identical. `@"..."` is the in-band escape that survives: + +``` +[set Name @"null" -- the four-letter string, not a null +[set Message @"1060847" -- the string "1060847", not cliloc 1060847 +[set Message #1060847 -- cliloc 1060847, explicitly +[set Message 1060847 -- cliloc 1060847 (a bare integer is always a cliloc) +``` + +`[get` writes the same form back for any value that would otherwise be misread, so its output can +be pasted straight into `[set`. + +### The one inconsistency: `null` + +`where` spells a null constant as a bare `null`. `[set` and friends use `(-null-)`, and read a bare +`null` as the four-letter string. This predates the shared parser and is preserved deliberately — +every existing `where … = null` clause depends on it. + +``` +[global count where Item Name = null -- Name is null +[set Name (-null-) -- set Name to null +[set Name null -- set Name to the string "null" +``` + +## `distinct`, `order by`, `limit` + +``` +[global interface where Item order by Hue desc limit 50 +[global interface where Mobile distinct Name order by Name +``` + +- `distinct [ …]` — keeps one object per distinct combination of those properties. It + sorts internally to do so, so it also reorders the set; add `order by` if the order matters. +- `order by [direction] [ …]` — `by` is optional. Direction is `+`/`up`/`asc`/`ascending` + or `-`/`down`/`desc`/`descending`, defaulting to ascending. Multiple keys break ties left to right. +- `limit ` — keeps the first `n` after the others have run. + +Keywords are case-insensitive, and **the order you type them does not matter**: they always apply +as `where` → `distinct` → `order by` → `limit`. + +## `[interface` + +``` +[ interface [view ] [condition] +``` + +Opens a gump listing every match instead of acting on them. Each row can be inspected, and `view` +adds columns for the properties you name. This is the safest way to see what a condition selects +before running something destructive with the same clause. + +``` +[global interface where Item Movable = false ItemID = 0x1F13 +[global interface view Hue Name where Mobile Hits < 10 +``` + +## `[batch` + +`[batch` opens a gump that runs **several commands against one found set**. Type `[batch` with no +arguments; the gump has three parts: + +- **Scope** — pick one of the scopes above. +- **Condition** — the whole clause, and it must start with `where`. +- **Commands** — one or more entries, each with a command and an optional **Object**. + +Every command runs against the same matched set, in the order listed. It is the tool for "find +these once, then do three things to them" without re-running an expensive sweep, and for +multi-step edits that would otherwise race against their own filter — a `where Hue = 0` clause +re-evaluated after the first command has changed `Hue` would no longer match. + +The **Object** field is a property chain that redirects that one command onto a sub-object of each +match. Leave it blank to act on the match itself; set it to `Backpack` to act on each mobile's +backpack instead. Objects whose chain is null or unreadable are skipped for that command. + +Logging is suppressed automatically when the set is larger than 20 objects. + +## Gotchas + +- The type after `where` is mandatory; `where Movable = true` is a parse error, not a wildcard. +- Only `[CommandProperty]` members are reachable, and write access is checked separately from read. +- Relational operators need a comparable type; equality is available on everything. +- A bare integer targeting a `TextDefinition` is always a cliloc. Use `@"123"` for the string. +- `where … = null` and `[set … (-null-)` are different spellings of the same idea. See above. +- Spawner **Params** are split on plain spaces, not the quoting tokenizer, so a constructor + argument cannot contain a space. Spawner **Props** use the normal tokenizer and value syntax. +- Test a destructive clause with `[interface` or `count` first. + +## Key files + +| Concern | File | +|---|---| +| Scopes | `Projects/UOContent/Commands/Generic/Implementors/` | +| `where` parsing, operators | `Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs` | +| Condition/sort/distinct compilation | `Projects/UOContent/Commands/Generic/Extensions/Compilers/` | +| Modifier parsing and apply order | `Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs` | +| Value parsing | `Projects/UOContent/Utilities/Types.cs` | +| Command tokenizer | `Projects/Server/Commands.cs` (`Commands.Split`) | +| `[batch` | `Projects/UOContent/Commands/Batch.cs` | +| `[interface` | `Projects/UOContent/Commands/Generic/Commands/Interface.cs` | + +## See also + +- `dev-docs/commands-targeting.md` — registering commands and the targeting system. +- — the full command list, regenerated with distro updates. From 51d2e998fcdf31f7e09826cb8c5df33988b814e4 Mon Sep 17 00:00:00 2001 From: Robert Dickey Date: Fri, 11 Sep 2026 19:26:06 -0500 Subject: [PATCH 15/21] fix: respect active region MountsAllowed for players (#2630) A player can mount a horse or validate an ethereal mount inside a BaseRegion that overrides MountsAllowed to false because CheckMountAllowed never consults that property. This adds the missing check in the shared player permission path, using existing localized message 1042317. --- .../Tests/Mobiles/MountRegionTests.cs | 96 +++++++++++++++++++ .../Mobiles/Animals/Mounts/BaseMount.cs | 7 ++ 2 files changed, 103 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs new file mode 100644 index 000000000..6369ddda9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Server.Items; +using Server.Regions; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +[Collection("Sequential UOContent Tests")] +public class MountRegionTests +{ + public static IEnumerable MountCases() + { + foreach (var era in Enum.GetValues()) + { + foreach (var allowed in new[] { false, true }) + { + yield return new object[] { era, allowed, false }; + yield return new object[] { era, allowed, true }; + } + } + } + + [Theory] + [MemberData(nameof(MountCases))] + public void Mounting_RespectsActiveRegion(Expansion era, bool allowed, bool ethereal) + { + var previous = Core.Expansion; + var region = new MountTestRegion(allowed); + PlayerMobile player = null; + TestHorse horse = null; + EtherealHorse statue = null; + try + { + Core.Expansion = era; + region.Register(); + player = new PlayerMobile(World.NewMobile); + player.DefaultMobileInit(); + player.Race = Race.Human; + player.AddItem(new Backpack()); + player.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + Assert.Same(region, player.Region); + + if (ethereal) + { + statue = new EtherealHorse { IsRewardItem = false }; + player.Backpack.DropItem(statue); + Assert.Same(player.Backpack, statue.Parent); + Assert.Equal(allowed, statue.Validate(player)); + } + else + { + horse = new TestHorse(); + horse.MoveToWorld(player.Location, player.Map); + horse.SetControlMaster(player); + horse.OnDoubleClick(player); + Assert.Equal(allowed, horse.Rider == player); + Assert.Equal(allowed, player.Mounted); + } + } + finally + { + horse?.Delete(); + statue?.Delete(); + player?.Delete(); + region.Unregister(); + Core.Expansion = previous; + } + } + + private class MountTestRegion : BaseRegion + { + private readonly bool _allowed; + + public MountTestRegion(bool allowed) + : base("MountRegionTest", Map.Felucca, 100, + new Rectangle3D(990, 990, -128, 20, 20, 256)) + { + _allowed = allowed; + } + + public override bool MountsAllowed => _allowed; + } + + // The test fixture does not configure NPCSpeeds. + private class TestHorse : Horse + { + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } +} diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs index 956f13034..43e7a598c 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs @@ -3,6 +3,7 @@ using System; using Server.Items; using Server.Misc; using Server.Multis; +using Server.Regions; using Server.Targeting; namespace Server.Mobiles; @@ -286,6 +287,12 @@ public abstract partial class BaseMount : BaseCreature, IMount if (mob is PlayerMobile mobile) { + if (mobile.Region is BaseRegion { MountsAllowed: false }) + { + mobile.SendLocalizedMessage(1042317); // You may not ride at this time + result = false; + } + if (mobile.MountBlockReason != BlockMountType.None) { mobile.SendLocalizedMessage((int)mobile.MountBlockReason); From 75f326bfdd8ccf30ee5c07f0b3510f2c69edcd1b Mon Sep 17 00:00:00 2001 From: Robert Dickey Date: Fri, 11 Sep 2026 19:26:47 -0500 Subject: [PATCH 16/21] fix: handle null maps when deactivating creature AI (#2629) An uncontrolled creature with `Map == null` throws in `BaseAI.Deactivate()` when the condition reaches `Map.GetSector()`. A controlled creature with a null map avoids the exception but leaves its AI timer running. Treat a null map like `Map.Internal` in the existing stop condition. This stops the timer without dereferencing the missing map and leaves the existing valid-map condition and return-home scheduling unchanged. Addresses only the null-map `Deactivate()` item in #2627; the other audit items remain separate. No era-specific rules are changed. --- .../Tests/Mobiles/AI/AIDeactivationTests.cs | 33 +++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs new file mode 100644 index 000000000..fc3f82aa8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs @@ -0,0 +1,33 @@ +using Server; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class AIDeactivationTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Deactivate_WithoutWorldMap_StopsTimer(bool internalMap, bool controlled) + { + var creature = new PetTestStub(); + try + { + creature.Controlled = controlled; + creature.Map = internalMap ? Map.Internal : null; + creature.AIObject.Activate(); + Assert.True(creature.AIObject.AITimer.Running); + + creature.AIObject.Deactivate(); + + Assert.False(creature.AIObject.AITimer.Running); + } + finally + { + creature.Delete(); + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index e7b16be9c..e762c5c61 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -1102,7 +1102,7 @@ public abstract partial class BaseAI public virtual void Deactivate() { - if (Mobile.Map == Map.Internal || !Mobile.Controlled && !Mobile.Map.GetSector(Mobile.Location).Active) + if (Mobile.Map == null || Mobile.Map == Map.Internal || !Mobile.Controlled && !Mobile.Map.GetSector(Mobile.Location).Active) { AITimer.Stop(); } From a52ce6ef705184117dbd008c42dcad577c99add4 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:41:08 -0700 Subject: [PATCH 17/21] refactor(spawners): subclass-owned entries, lifecycle hooks, per-entry Disabled flag (#2621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Moves spawner entry storage out of the abstract `BaseSpawner` into the concrete owner, so a spawner subclass can store its own entry type while every stock code path keeps working. Motivation: an out-of-tree spawner (ModernSpawner) needs `ModernSpawnerEntry : SpawnerEntry` with extra fields; today `BaseSpawner` owns `List` and a family of non-virtual members, and the serialization generator constructs list elements from the declared element type, so storage has to live in the class that declares the concrete list. ### What changed - **`BaseSpawner` v13** no longer owns `_entries`. It reads entries through an abstract view and mutates them through an owner contract (`BaseSpawner.Entries.cs`): `Entries` (`IReadOnlyList`, `[IgnoreDupe]`), `EntrySpan` (`ReadOnlySpan` for hot loops), `CreateEntry`, `AddEntryCore`, `RemoveEntryCore`, `ClearEntriesCore`, `AdoptEntries`, `CloneEntry`, `TransferSpawned`, plus public `RemoveAllEntries()`, `CopyEntriesTo(target)` and protected `RebuildSpawned()`. Every loop inside `BaseSpawner` is an indexed `for` over `EntrySpan`. - **`Spawner` v2** owns `[SerializedIgnoreDupe] List _entryList` (generated `EntryList`, protected). `ProximitySpawner`/`RegionSpawner` inherit it unchanged. The `Spawned` rebuild and timer re-arm moved from the base `[AfterDeserialization]` (which runs before derived fields are read) into `Spawner`'s. - **Save migration**: `MigrateFrom(V12Content)` (and the v10/v11/legacy readers) hand the old list to the owner via `AdoptEntries`; `Spawner.MigrateFrom(V1Content)` restores its own fields and leaves the adopted list alone. Three v12/v1/v0 save blobs captured before the change are committed as fixtures and loaded by tests. - **Lifecycle hooks** (`BaseSpawner.Hooks.cs`, all no-op by default): `OnStarted`, `OnStopped`, `OnBeforeSpawn(entry)` veto, `OnConfigureSpawned(entry, spawned)` before positioning, entry-aware `GetSpawnPosition(entry, spawned, map)`, `OnSpawned(entry, spawned)`, `OnSpawnedDeath(entry, spawned, killer)`. `BaseCreature.OnDeath` calls `NotifySpawnedDeath` before base death (which deletes the mobile and unlinks the spawner). `Start()` and the `NextSpawn` setter share one start core so `OnStarted` fires on both. - **`SpawnerEntry` v2**: per-entry `Disabled` (XmlSpawner's entry "lock"), stored inverted so the common case writes nothing in binary or JSON; skipped by weighted selection, live spawns untouched; toggle button per row in `SpawnerGump`. `SetParent` is public and `Parent` is protected so an out-of-tree entry subclass can adopt and dirty-track. - **DTO**: `SpawnerDto` loses `Entries`; each concrete record declares its own `entries` at the same JSON order, so `Distribution/Data/Spawns/**` is byte-identical. Import adopts the deserialized entry objects instead of recreating them through `AddEntry`, which is what preserves subtype fields (and `disabled`). ### Breaking changes and behaviour changes - **API:** `BaseSpawner.Entries` is `IReadOnlyList` instead of `List`. The generated `AddToEntries`/`RemoveFromEntries`/`InsertIntoEntries`/`RemoveFromEntriesAt`/`ClearEntries` helpers on `BaseSpawner` are gone; use `AddEntry`/`RemoveEntry`/`RemoveAllEntries`/`CopyEntriesTo`. `RemoveAllEntries()` deletes the entries' live spawns as well as the entries (the old generated `ClearEntries()` only cleared the list), which is why it has a new name rather than the old one. - `SpawnerControllerGump` "copy entries" now goes through `CopyEntriesTo`, which deletes the target's live spawns (previously it cleared the list and left the spawns orphaned) and is a no-op when source == target (previously that wiped the source). - `RemoveEntry` with an entry the spawner does not own is now a no-op (previously it deleted that entry's spawns). - `Respawn()` honours `Disabled` because it calls `Spawn()`; `Spawn(int index)`, `RemoveSpawn`, and `RemoveSpawns` ignore it. - Copying entries between spawners no longer forces a 1-second first spawn; the target re-arms on its normal delay. - Subclasses that own a different entry list than `Spawner`'s must call `RebuildSpawned()` from their own `[AfterDeserialization]` (`Spawner`'s call runs before their list is read) and, when converting adopted entries into their own type, carry live spawns across with `TransferSpawned`. The in-repo test subclass demonstrates both. ### Performance Manual harness (`Benchmark_SpawnPath_Manual`, skipped by default): 100k calls, entries all full so `Spawn()` does selection only. | Path | Before (4bad0cc9e) | After | |---|---|---| | `Spawn()` 1 entry | 48.7 ns | 53.2 ns (within run-to-run noise) | | `Spawn()` 10 entries | 243.7 ns | 153.1 ns | | `Spawn()` 50 entries | 1077.6 ns | 645.1 ns | | `Remove()` 10 entries | 139.6 ns | 83.6 ns | Hooks are no-ops for stock spawners; `OnMovement` is untouched. ### Tests - `SpawnerEntryOwnershipTests` (add/remove/clear, start after stop, dupe, copy, self-copy, foreign-entry removal, Disabled binary/JSON) - `SpawnerHookTests` (hook order for mobiles and items, veto, death notification, `NextSpawn` start, a subclass with its own `List` round-tripping and duping) - `SpawnerSaveMigrationTests` (v12 fixtures through `Spawner`, `ProximitySpawner`, `RegionSpawner`; new-format byte-identical round trip with a live spawn reference) - `SpawnerDtoEntryTests` (compact JSON position of `entries`, `disabled` only when set, import adopts the deserialized objects) - Existing DTO/JSON/spawn-data tests unchanged. UOContent.Tests 801 passed, Server.Tests 869 passed. Migration schemas regenerated (`BaseSpawner.v13`, `Spawner.v2`, `SpawnerEntry.v2`). Coverage caveats, stated plainly: v10, v11 and the pre-codegen legacy reader could not be captured as fixtures by the current code; each changed only `_entries = …` → `AdoptEntries(…)` into the same sink the v12 fixtures exercise, and is covered by review. The captured v12 fixtures carry no live spawns, so re-linking live `ISpawnable` references is proven by the new-format round trip, not by a legacy blob. The `SpawnerGump` toggle layout could not be checked in a client; the delete button moved from x=38 to x=46 to make room. --- .../Fixtures/TestServerInitializer.cs | 2 + .../Spawners/Fixtures/proximity.v12-v1-v0.bin | Bin 0 -> 176 bytes .../Spawners/Fixtures/region.v12-v1-v0.bin | Bin 0 -> 164 bytes .../Spawners/Fixtures/spawner.v12-v1.bin | Bin 0 -> 172 bytes .../Json/SpawnerDiscoveryValidationTests.cs | 4 + .../Spawners/Json/SpawnerDtoEntryTests.cs | 57 ++++ .../Spawners/SpawnerEntryOwnershipTests.cs | 243 ++++++++++++++ .../Engines/Spawners/SpawnerFixtureCapture.cs | 50 +++ .../Engines/Spawners/SpawnerHookTests.cs | 305 ++++++++++++++++++ .../Spawners/SpawnerSaveMigrationTests.cs | 90 ++++++ .../UOContent.Tests/UOContent.Tests.csproj | 5 + .../Engines/Spawners/BaseSpawner.Dto.cs | 8 +- .../Engines/Spawners/BaseSpawner.Entries.cs | 172 ++++++++++ .../Engines/Spawners/BaseSpawner.Hooks.cs | 53 +++ .../Spawners/BaseSpawner.Migrations.cs | 31 +- .../UOContent/Engines/Spawners/BaseSpawner.cs | 166 ++++------ .../Spawners/Commands/EditSpawnerCommand.cs | 4 +- .../Engines/Spawners/Json/SpawnerDto.cs | 33 +- .../Engines/Spawners/ProximitySpawner.Dto.cs | 2 +- .../Engines/Spawners/RegionSpawner.Dto.cs | 2 +- .../UOContent/Engines/Spawners/Spawner.Dto.cs | 2 +- .../UOContent/Engines/Spawners/Spawner.cs | 76 ++++- .../Engines/Spawners/SpawnerControllerGump.cs | 15 +- .../Engines/Spawners/SpawnerEntry.cs | 44 ++- .../UOContent/Engines/Spawners/SpawnerGump.cs | 47 ++- ...rver.Engines.Spawners.BaseSpawner.v13.json | 113 +++++++ .../Server.Engines.Spawners.Spawner.v2.json | 34 ++ ...rver.Engines.Spawners.SpawnerEntry.v2.json | 65 ++++ Projects/UOContent/Mobiles/BaseCreature.cs | 5 + 29 files changed, 1489 insertions(+), 139 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs create mode 100644 Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs create mode 100644 Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs create mode 100644 Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json create mode 100644 Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 5e4230b7c..bca909a3e 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -109,6 +109,8 @@ internal static class TestServerInitializer World.Load(); World.ExitSerializationThreads(); DecayScheduler.Configure(); + // Without npc-speeds.json every BaseCreature constructor throws. + Server.Mobiles.NPCSpeeds.Configure(); Server.Engines.Spawners.SpawnerJsonSerializer.Configure(); if (TileDataLoaded) diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin new file mode 100644 index 0000000000000000000000000000000000000000..29db020bc256d7cbe35b73aa8e3b1c0c3368e2bd GIT binary patch literal 176 zcmd;PU|@(5J4OMw%!-oIqSSz*{EE!n%#un59-yKbtUQf5FPSFV zc@(5J4Rl&%!-oIqST<&^vwJ`1|Fb_8LTzR$}ARp^t`*+ zE^g_cH>;P45h%>a>|d0e0%8C$lwxEJDay~uK@$BB1PpG6mBC7S+T7tPZ&bVo+X)t6 U`U6x3!VN&o3KRn229Oj304$g*e*gdg literal 0 HcmV?d00001 diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..a8653ab9bc2e4584761919975e63d6be418a4cf0 GIT binary patch literal 172 zcmd;PU|@(5J4QaY%!-oIqSWAm#PYn EntryView => null; + protected override BaseSpawner CreateEmpty() => new Spawner(); } [JsonDiscoverableType("dup")] private sealed record DupB : SpawnerDto { + public override IReadOnlyList EntryView => null; + protected override BaseSpawner CreateEmpty() => new Spawner(); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs new file mode 100644 index 000000000..8a25c4b0f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class SpawnerDtoEntryTests +{ + [Fact] + public void Export_WritesEntriesAtSameJsonPosition_WithDisabledOnlyWhenSet() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit", "Bird"); + spawner.Entries[1].Disabled = true; + + var json = SpawnerJsonSerializer.SerializeCompact(new List { spawner.ToDto() }); + + Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json); + Assert.Contains("{ \"name\": \"Bird\"", json); + Assert.Equal(1, CountOccurrences(json, "\"disabled\": true")); + + spawner.Delete(); + } + + [Fact] + public void Import_AdoptsDeserializedEntryObjects() + { + var json = """ + [{"$type":"Spawner","guid":"11111111-1111-1111-1111-111111111111","location":[1500,1500,0],"map":"Felucca","count":1,"minDelay":"00:05:00","maxDelay":"00:10:00","homeRange":4,"entries":[{"name":"Rabbit","probability":100,"maxCount":1,"disabled":true}]}] + """; + var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + var spawner = dtos![0].ToSpawner(); + + Assert.Single(spawner.Entries); + Assert.True(spawner.Entries[0].Disabled); + Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]); + + spawner.Delete(); + } + + private static int CountOccurrences(string source, string value) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + + return count; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs new file mode 100644 index 000000000..767a3289d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Server.Tests; +using Server.Text; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +[Collection("Sequential UOContent Tests")] +public class SpawnerEntryOwnershipTests +{ + [Fact] + public void Disabled_DefaultsFalse_AndRoundTripsBinary() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + Assert.False(spawner.Entries[0].Disabled); + Assert.True(spawner.Entries[0].Enabled); + + spawner.Entries[1].Disabled = true; + + var bytes = SpawnerBlob.Write(spawner); + var loaded = SpawnerBlob.Read(bytes, (Serial)0x40001234); + + Assert.Equal(2, loaded.Entries.Count); + Assert.False(loaded.Entries[0].Disabled); + Assert.True(loaded.Entries[1].Disabled); + + spawner.Delete(); + loaded.Delete(); + } + + [Fact] + public void Disabled_IsOmittedFromJsonWhenFalse_AndWrittenWhenTrue() + { + var entry = new SpawnerEntry("Rabbit"); + var json = JsonSerializer.Serialize(entry, SpawnerJsonSerializer.Options); + Assert.DoesNotContain("disabled", json); + + entry.Disabled = true; + json = JsonSerializer.Serialize(entry, SpawnerJsonSerializer.Options); + Assert.Contains("\"disabled\": true", json); + } + + [Fact] + public void AddRemoveClear_OperateOnOwnerList() + { + var spawner = new Spawner(); + Assert.Empty(spawner.Entries); + + var a = spawner.AddEntry("Rabbit", 100, 2, false); + var b = spawner.AddEntry("Bird", 50, 1, false, "Hue 33", null); + Assert.Equal(2, spawner.Entries.Count); + Assert.Same(a, spawner.Entries[0]); + Assert.Equal("Hue 33", spawner.Entries[1].Properties); + + spawner.RemoveEntry(a); + Assert.Single(spawner.Entries); + Assert.Same(b, spawner.Entries[0]); + + spawner.RemoveAllEntries(); + Assert.Empty(spawner.Entries); + + spawner.Delete(); + } + + [Fact] + public void Start_WorksAfterStop_WhenOwnerHasEntries() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); + Assert.True(spawner.Running); + spawner.Stop(); + Assert.False(spawner.Running); + spawner.Start(); + Assert.True(spawner.Running); + spawner.Delete(); + } + + [Fact] + public void Dupe_ClonesEntriesIntoIndependentList() + { + var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + source.Entries[1].Disabled = true; + + var copy = new Spawner(); + source.Dupe(copy); + + Assert.Equal(2, copy.Entries.Count); + Assert.NotSame(source.Entries[0], copy.Entries[0]); + Assert.Equal("Bird", copy.Entries[1].SpawnedName); + Assert.True(copy.Entries[1].Disabled); + Assert.NotEqual(source.Guid, copy.Guid); + + source.AddEntry("Orc", 100, 1, false); + Assert.Equal(2, copy.Entries.Count); + + source.Delete(); + copy.Delete(); + } + + [Fact] + public void CopyEntriesTo_ReplacesTargetEntries() + { + var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); + var target = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Orc", "Troll"); + + source.CopyEntriesTo(target); + + Assert.Single(target.Entries); + Assert.Equal("Rabbit", target.Entries[0].SpawnedName); + Assert.NotSame(source.Entries[0], target.Entries[0]); + + source.Delete(); + target.Delete(); + } + + [Fact] + public void CopyEntriesTo_Self_IsNoOp() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var first = spawner.Entries[0]; + var second = spawner.Entries[1]; + + spawner.CopyEntriesTo(spawner); + + Assert.Equal(2, spawner.Entries.Count); + Assert.Same(first, spawner.Entries[0]); + Assert.Same(second, spawner.Entries[1]); + + spawner.Delete(); + } + + [Fact] + public void RemoveEntry_ForeignEntry_IsIgnored() + { + var a = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); + var b = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Bird"); + + var foreign = b.Entries[0]; + var spawned = new Item(0x1f13); + foreign.AddToSpawned(spawned); + + a.RemoveEntry(foreign); + + Assert.Single(a.Entries); + Assert.Equal("Rabbit", a.Entries[0].SpawnedName); + Assert.Single(b.Entries); + Assert.Same(foreign, b.Entries[0]); + + Assert.False(spawned.Deleted); + Assert.Single(foreign.Spawned); + + spawned.Delete(); + a.Delete(); + b.Delete(); + } + + // Manual: remove the Skip, run, and read the numbers from the thrown assertion. + [Fact(Skip = "manual benchmark")] + public void Benchmark_SpawnPath_Manual() + { + const int iterations = 100_000; + const int warmup = 1_000; + + var report = new ValueStringBuilder(stackalloc char[512]); + report.Append('\n', 1); + + foreach (var entryCount in new[] { 1, 10, 50 }) + { + var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + + // SpawnedMaxCount = 0 keeps every entry full, so Spawn() does selection only. + for (var i = 0; i < entryCount; i++) + { + spawner.AddEntry("Rabbit", 100, 0, false); + } + + for (var i = 0; i < warmup; i++) + { + spawner.Spawn(); + } + + var sw = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + spawner.Spawn(); + } + + sw.Stop(); + + var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; + report.Append( + $"Spawn() entries={entryCount,2}: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" + ); + + spawner.Delete(); + } + + { + var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + + for (var i = 0; i < 10; i++) + { + spawner.AddEntry("Rabbit", 100, 1, false); + } + + spawner.Spawn(); + Assert.Single(spawner.Spawned); + var rabbit = spawner.Spawned.Keys.First(); + + for (var i = 0; i < warmup; i++) + { + spawner.Remove(rabbit); + } + + var sw = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + spawner.Remove(rabbit); + } + + sw.Stop(); + + var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; + report.Append( + $"Remove(ISpawnable) entries=10: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" + ); + + (rabbit as Mobile)?.Delete(); + spawner.Delete(); + } + + var summary = report.ToString(); + report.Dispose(); + + throw new Xunit.Sdk.XunitException(summary); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs new file mode 100644 index 000000000..d3f48792a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using System.Reflection; +using Server.Engines.Spawners; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +[Collection("Sequential UOContent Tests")] +public class SpawnerFixtureCapture +{ + // Freezes the BaseSpawner v12 save layout as test input; refuses to run against newer code. + [SkippableFact] + public void CaptureLegacyBlobs() + { + Skip.If(Environment.GetEnvironmentVariable("MODERNUO_CAPTURE_SPAWNER_FIXTURES") != "1"); + + var version = (int)typeof(BaseSpawner) + .GetField("SerializationVersion", BindingFlags.NonPublic | BindingFlags.Static)! + .GetRawConstantValue()!; + Assert.True(version == 12, $"Fixtures must be captured with BaseSpawner v12; current version is {version}."); + + var dir = Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures"); + Directory.CreateDirectory(dir); + + var spawner = new Spawner(2, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird") + { + Name = "FixtureSpawner" + }; + spawner.Entries[1].Properties = "Hue 33"; + File.WriteAllBytes(Path.Combine(dir, "spawner.v12-v1.bin"), SpawnerBlob.Write(spawner)); + + var proximity = new ProximitySpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, 5, "boo", true, "Rat", "Bird") + { + Name = "FixtureProximity" + }; + File.WriteAllBytes(Path.Combine(dir, "proximity.v12-v1-v0.bin"), SpawnerBlob.Write(proximity)); + + var region = new RegionSpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, "Orc", "Troll") + { + Name = "FixtureRegion" + }; + File.WriteAllBytes(Path.Combine(dir, "region.v12-v1-v0.bin"), SpawnerBlob.Write(region)); + + spawner.Delete(); + proximity.Delete(); + region.Delete(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs new file mode 100644 index 000000000..4450e1dea --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ModernUO.Serialization; +using Server; +using Server.Engines.Spawners; +using Server.Mobiles; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +[SerializationGenerator(0)] +public partial class TestEntry : SpawnerEntry +{ + // Dirty tracking is resolved on the declared type, so a derived entry re-declares its owner. + [DirtyTrackingEntity] + private BaseSpawner Owner => Parent; + + [SerializableField(0)] + private string _tag; + + public TestEntry(BaseSpawner parent) : base(parent) + { + } + + public TestEntry(BaseSpawner parent, string name, int probability, int maxCount, string properties, string parameters) + : base(parent, name, probability, maxCount, properties, parameters) + { + } +} + +[SerializationGenerator(0)] +public partial class HookRecordingSpawner : Spawner +{ + [SerializedIgnoreDupe] + [SerializableField(0)] + private List _testEntries; + + public List Log { get; } = []; + public bool VetoNext { get; set; } + + public HookRecordingSpawner() + { + } + + public HookRecordingSpawner(Serial serial) : base(serial) + { + } + + public override IReadOnlyList Entries => _testEntries ?? (IReadOnlyList)Array.Empty(); + + protected override ReadOnlySpan EntrySpan => + ReadOnlySpan.CastUp(CollectionsMarshal.AsSpan(_testEntries)); + + protected override SpawnerEntry CreateEntry(string name, int probability, int maxCount, string properties, string parameters) => + new TestEntry(this, name, probability, maxCount, properties, parameters) { Tag = "made" }; + + protected override void AddEntryCore(SpawnerEntry entry) + { + TestEntries ??= []; + AddToTestEntries((TestEntry)entry); + } + + protected override bool RemoveEntryCore(SpawnerEntry entry) + { + if (entry is not TestEntry te || _testEntries?.Contains(te) != true) + { + return false; + } + + RemoveFromTestEntries(te); + return true; + } + + protected override void ClearEntriesCore() + { + if (_testEntries?.Count > 0) + { + ClearTestEntries(); + } + } + + protected override void AdoptEntries(IReadOnlyList entries) + { + ClearEntriesCore(); + for (var i = 0; i < entries.Count; i++) + { + var e = entries[i]; + TestEntry te; + if (e is TestEntry existing) + { + te = existing; + } + else + { + te = (TestEntry)CloneEntry(e); + TransferSpawned(e, te); + } + + te.SetParent(this); + AddEntryCore(te); + } + } + + /// Test hook: adopt entries built elsewhere, exercising the conversion path. + public void AdoptForTest(IReadOnlyList entries) => AdoptEntries(entries); + + /// Test hook: rebuild the Spawned registry without a full save round trip. + public void RebuildSpawnedForTest() => RebuildSpawned(); + + // Spawner's rebuild runs before _testEntries is read; rebuild again here. + [AfterDeserialization] + private void AfterDeserialization() => RebuildSpawned(); + + protected override SpawnerEntry CloneEntry(SpawnerEntry source) + { + var clone = (TestEntry)base.CloneEntry(source); + clone.Tag = (source as TestEntry)?.Tag ?? clone.Tag; + return clone; + } + + protected override void OnStarted() => Log.Add("started"); + protected override void OnStopped() => Log.Add("stopped"); + + protected override bool OnBeforeSpawn(SpawnerEntry entry) + { + Log.Add($"before:{entry.SpawnedName}"); + if (VetoNext) + { + VetoNext = false; + return false; + } + + return true; + } + + protected override void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned) => Log.Add($"configure:{entry.SpawnedName}"); + + protected override Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map) + { + Log.Add($"position:{entry.SpawnedName}"); + return Location; + } + + protected override void OnSpawned(SpawnerEntry entry, ISpawnable spawned) => Log.Add($"spawned:{entry.SpawnedName}"); + + protected override void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer) => + Log.Add($"death:{entry.SpawnedName}:{killer?.Name ?? "none"}"); +} + +[Collection("Sequential UOContent Tests")] +public class SpawnerHookTests +{ + private static HookRecordingSpawner Place() + { + var spawner = new HookRecordingSpawner(); + spawner.InitSpawn(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + return spawner; + } + + [Fact] + public void Hooks_FireInOrder_OnSpawnAndStartStop() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.Log.Clear(); + + spawner.Stop(); + spawner.Start(); + spawner.Spawn(); + + Assert.Equal( + ["stopped", "started", "before:Rabbit", "configure:Rabbit", "position:Rabbit", "spawned:Rabbit"], + spawner.Log + ); + Assert.Single(spawner.Spawned); + + spawner.Delete(); + } + + [Fact] + public void Hooks_FireForItemEntries() + { + var spawner = Place(); + spawner.AddEntry("Gold", 100, 1, false); + spawner.Log.Clear(); + + spawner.Spawn(); + + Assert.Equal( + ["before:Gold", "configure:Gold", "position:Gold", "spawned:Gold"], + spawner.Log + ); + Assert.IsAssignableFrom(Assert.Single(spawner.Spawned).Key); + + spawner.Delete(); + } + + [Fact] + public void NextSpawn_OnStoppedSpawner_FiresOnStarted() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.Stop(); + spawner.Log.Clear(); + + spawner.NextSpawn = TimeSpan.FromSeconds(5); + + Assert.True(spawner.Running); + Assert.Equal(["started"], spawner.Log); + + spawner.Delete(); + } + + [Fact] + public void OnBeforeSpawn_CanVeto() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.VetoNext = true; + + spawner.Spawn(); + + Assert.Empty(spawner.Spawned); + Assert.Contains("before:Rabbit", spawner.Log); + Assert.DoesNotContain("spawned:Rabbit", spawner.Log); + + spawner.Delete(); + } + + [Fact] + public void Death_NotifiesOwningSpawner_BeforeUnlink() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.Spawn(); + var rabbit = Assert.Single(spawner.Spawned).Key as BaseCreature; + Assert.NotNull(rabbit); + + var killer = new PlayerMobile { Name = "Hunter" }; + rabbit.LastKiller = killer; + rabbit.Kill(); + + Assert.Contains("death:Rabbit:Hunter", spawner.Log); + + killer.Delete(); + spawner.Delete(); + } + + [Fact] + public void OwnEntryType_SurvivesBinaryRoundTripAndDupe() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + ((TestEntry)spawner.Entries[0]).Tag = "kept"; + spawner.Spawn(); + Assert.Single(spawner.Spawned); + + var loaded = SpawnerBlob.Read(SpawnerBlob.Write(spawner), (Serial)0x40009999); + Assert.Equal("kept", ((TestEntry)loaded.Entries[0]).Tag); + + Assert.Single(loaded.Entries[0].Spawned); + Assert.Single(loaded.Spawned); + + var copy = new HookRecordingSpawner(); + spawner.Dupe(copy); + Assert.Equal("kept", ((TestEntry)copy.Entries[0]).Tag); + Assert.Empty(copy.Entries[0].Spawned); + + spawner.Delete(); + loaded.Delete(); + copy.Delete(); + } + + [Fact] + public void AdoptEntries_ConvertsForeignEntries_KeepingLiveSpawns() + { + var source = new Spawner(); + source.InitSpawn(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + source.MoveToWorld(new Point3D(1502, 1502, 0), Map.Felucca); + source.AddEntry("Rabbit", 100, 1, false); + source.Spawn(); + var rabbit = Assert.Single(source.Spawned).Key; + + var adopter = Place(); + adopter.AdoptForTest(source.Entries); + + var adopted = Assert.Single(adopter.Entries); + Assert.IsType(adopted); + Assert.Equal("Rabbit", adopted.SpawnedName); + Assert.Same(rabbit, Assert.Single(adopted.Spawned)); + Assert.Empty(source.Entries[0].Spawned); + + adopter.RebuildSpawnedForTest(); + Assert.Same(rabbit, Assert.Single(adopter.Spawned).Key); + + source.Delete(); + Assert.False(rabbit.Deleted); + + adopter.Delete(); + Assert.True(rabbit.Deleted); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs new file mode 100644 index 000000000..870ae1161 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using Server; +using Server.Engines.Spawners; +using Server.Items; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +internal static class SpawnerBlob +{ + public static byte[] Write(Item item) + { + var writer = new BufferWriter(true); + item.Serialize(writer); + return writer.Buffer[..(int)writer.Position]; + } + + public static T Read(byte[] bytes, Serial serial) where T : Item + { + var item = (T)Activator.CreateInstance(typeof(T), serial)!; + var reader = new BufferReader(bytes); + item.Deserialize(reader); + return item; + } +} + +[Collection("Sequential UOContent Tests")] +public class SpawnerSaveMigrationTests +{ + private static byte[] Fixture(string name) => + File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures", name)); + + [Fact] + public void V12Spawner_EntriesAreAdoptedByOwner() + { + var loaded = SpawnerBlob.Read(Fixture("spawner.v12-v1.bin"), (Serial)0x40001001); + + Assert.Equal("FixtureSpawner", loaded.Name); + Assert.Equal(2, loaded.Entries.Count); + Assert.Equal("Rabbit", loaded.Entries[0].SpawnedName); + Assert.Equal("Hue 33", loaded.Entries[1].Properties); + Assert.False(loaded.Entries[0].Disabled); + Assert.NotNull(loaded.Spawned); + Assert.Empty(loaded.Spawned); + + loaded.Delete(); + } + + [Fact] + public void V12ProximityAndRegion_EntriesAreAdoptedThroughSubclasses() + { + var prox = SpawnerBlob.Read(Fixture("proximity.v12-v1-v0.bin"), (Serial)0x40001002); + Assert.Equal(2, prox.Entries.Count); + Assert.Equal("Rat", prox.Entries[0].SpawnedName); + Assert.Equal("Bird", prox.Entries[1].SpawnedName); + Assert.Equal(5, prox.TriggerRange); + Assert.True(prox.InstantFlag); + + var region = SpawnerBlob.Read(Fixture("region.v12-v1-v0.bin"), (Serial)0x40001003); + Assert.Equal(2, region.Entries.Count); + Assert.Equal("Orc", region.Entries[0].SpawnedName); + Assert.Equal("Troll", region.Entries[1].SpawnedName); + + prox.Delete(); + region.Delete(); + } + + [Fact] + public void NewFormat_RoundTripsByteIdentical_WithLiveSpawnReferences() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit"); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + spawner.Spawn(); + Assert.Single(spawner.Spawned); + + var first = SpawnerBlob.Write(spawner); + var loaded = SpawnerBlob.Read(first, (Serial)0x40001004); + Assert.Single(loaded.Entries); + Assert.Single(loaded.Entries[0].Spawned); + Assert.Single(loaded.Spawned); + + var second = SpawnerBlob.Write(loaded); + Assert.Equal(first, second); + + loaded.Delete(); + spawner.Delete(); + } +} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 1c73f6eb4..ed096038f 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -11,11 +11,16 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs index 5f7aaf1d0..aab965232 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs @@ -42,13 +42,9 @@ public abstract partial class BaseSpawner _spawnPositionMode = dto.SpawnPositionMode; _maxSpawnAttempts = dto.MaxSpawnAttempts; - if (dto.Entries != null) + if (dto.EntryView != null) { - for (var i = 0; i < dto.Entries.Count; i++) - { - var entry = dto.Entries[i]; - AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters); - } + AdoptEntries(dto.EntryView); } } diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs new file mode 100644 index 000000000..83e31c179 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using ModernUO.Serialization; + +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + /// + /// The entries this spawner cycles through, owned by the concrete subclass so it can use its own + /// entry type. Cold read-only view; loops inside BaseSpawner use . + /// + [IgnoreDupe] + public abstract IReadOnlyList Entries { get; } + + /// Zero-cost span over the owner's list for hot loops (no interface dispatch, no allocation). + protected abstract ReadOnlySpan EntrySpan { get; } + + /// Creates an entry of the owner's entry type, parented to this spawner. Not added. + protected abstract SpawnerEntry CreateEntry( + string name, + int probability, + int maxCount, + string properties, + string parameters + ); + + protected abstract void AddEntryCore(SpawnerEntry entry); + + protected abstract bool RemoveEntryCore(SpawnerEntry entry); + + protected abstract void ClearEntriesCore(); + + /// + /// Takes ownership of entries built elsewhere (a legacy save, a DTO import). The owner stores + /// them, re-parents them, and converts foreign entry types if it must. Replaces the current list. + /// + /// + /// An implementer that converts a foreign entry into its own entry type must carry the live spawns + /// across with ; deliberately does not copy + /// them, so a conversion that only clones orphans every creature the adopted entry owns. + /// + protected abstract void AdoptEntries(IReadOnlyList entries); + + /// + /// Moves the live spawns of onto . Use when an owner converts + /// an adopted entry into its own entry type; deliberately does not copy spawns. + /// + protected static void TransferSpawned(SpawnerEntry source, SpawnerEntry target) + { + var spawned = source.Spawned; + for (var i = 0; i < spawned.Count; i++) + { + target.AddToSpawned(spawned[i]); + } + + source.ClearSpawned(); + } + + /// Deep-copies an entry into this spawner's entry type. Override to carry subtype fields. + protected virtual SpawnerEntry CloneEntry(SpawnerEntry source) + { + var entry = CreateEntry( + source.SpawnedName, + source.SpawnedProbability, + source.SpawnedMaxCount, + source.Properties, + source.Parameters + ); + entry.Disabled = source.Disabled; + return entry; + } + + public SpawnerEntry AddEntry( + string creaturename, + int probability = 100, + int amount = 1, + bool dotimer = true, + string properties = null, + string parameters = null + ) + { + var entry = CreateEntry(creaturename, probability, amount, properties, parameters); + AddEntryCore(entry); + if (dotimer) + { + DoTimer(TimeSpan.FromSeconds(1)); + } + + return entry; + } + + public void RemoveEntry(SpawnerEntry entry) + { + Defrag(); + + if (!RemoveEntryCore(entry)) + { + return; + } + + RemoveSpawn(entry); + + if (_running && !IsFull && _timer?.Running != true) + { + DoTimer(); + } + + InvalidateProperties(); + } + + /// + /// Deletes every live spawn and removes every entry. Named for the deletion: before entry ownership + /// moved to the owner, the generator emitted a ClearEntries() here that only emptied the list. + /// + public void RemoveAllEntries() + { + RemoveSpawns(); + ClearEntriesCore(); + InvalidateProperties(); + } + + /// Replaces 's entries with clones of this spawner's entries. + public void CopyEntriesTo(BaseSpawner target) + { + // A self-copy would clear the source. + if (ReferenceEquals(target, this)) + { + return; + } + + target.RemoveAllEntries(); + + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) + { + target.AddEntryCore(target.CloneEntry(entries[i])); + } + + target.InvalidateProperties(); + } + + /// + /// Rebuilds the entity -> entry registry from the owner's entries and re-arms the timer. + /// The owner calls this from its own [AfterDeserialization] once its list is loaded; the base + /// hook runs before derived fields exist and must not touch entries. + /// + /// + /// calls this for its own entry list only. A subclass that owns a different + /// list (its own entry type, or an extra list) must call it again from its own + /// [AfterDeserialization]: the base class's runs before the derived fields have been read, + /// so the load would otherwise finish with an empty registry even though the + /// entries themselves carry their spawns. + /// + protected void RebuildSpawned() + { + Spawned = new Dictionary(); + + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) + { + var entry = entries[i]; + var spawned = entry.Spawned; + for (var j = 0; j < spawned.Count; j++) + { + Spawned.TryAdd(spawned[j], entry); + } + } + + DoTimer(_end - Core.Now); + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs new file mode 100644 index 000000000..ccc2a226d --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs @@ -0,0 +1,53 @@ +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + /// + /// Called after the timer starts (Start(), Running = true, NextSpawn on a stopped spawner). + /// Not called for construction (InitSpawn) or deserialization; subclasses initialise + /// run state in their constructor and [AfterDeserialization]. + /// + protected virtual void OnStarted() + { + } + + /// Called after the timer stops (Stop(), Running = false), and only if it was running. + protected virtual void OnStopped() + { + } + + /// Veto point before an entry's entity is constructed. Return false to skip this attempt. + protected virtual bool OnBeforeSpawn(SpawnerEntry entry) => true; + + /// + /// Runs after property application and before positioning, so computed properties apply first. + /// The entity is not yet in , has no Spawner set, and is still on + /// the internal map. + /// + protected virtual void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned) + { + } + + /// Entry-aware positioning. Default delegates to the entry-agnostic overload. + protected virtual Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map) => + GetSpawnPosition(spawned, map); + + /// Runs after the entity is in the world and linked to this spawner. + protected virtual void OnSpawned(SpawnerEntry entry, ISpawnable spawned) + { + } + + /// A spawned creature died (before base death deletes it and unlinks the spawner). + protected virtual void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer) + { + } + + /// Entry point for BaseCreature.OnDeath. Resolves the entry and dispatches the hook. + public void NotifySpawnedDeath(ISpawnable spawned, Mobile killer) + { + if (spawned != null && Spawned != null && Spawned.TryGetValue(spawned, out var entry)) + { + OnSpawnedDeath(entry, spawned, killer); + } + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs index 9c5c4f002..35d9c2433 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs @@ -13,7 +13,7 @@ public abstract partial class BaseSpawner { _guid = content.Guid; _returnOnDeactivate = content.ReturnOnDeactivate; - _entries = content.Entries; + AdoptEntries(content.Entries ?? []); _walkingRange = content.WalkingRange; _wayPoint = content.WayPoint; _group = content.Group; @@ -41,7 +41,7 @@ public abstract partial class BaseSpawner { _guid = content.Guid; _returnOnDeactivate = content.ReturnOnDeactivate; - _entries = content.Entries; + AdoptEntries(content.Entries ?? []); _walkingRange = content.WalkingRange; _wayPoint = content.WayPoint; _group = content.Group; @@ -65,21 +65,44 @@ public abstract partial class BaseSpawner _maxSpawnAttempts = DefaultMaxSpawnAttempts; } + private void MigrateFrom(V12Content content) + { + _guid = content.Guid; + _returnOnDeactivate = content.ReturnOnDeactivate; + _walkingRange = content.WalkingRange; + _wayPoint = content.WayPoint; + _group = content.Group; + _minDelay = content.MinDelay ?? DefaultMinDelay; + _maxDelay = content.MaxDelay ?? DefaultMaxDelay; + _count = content.Count; + _team = content.Team ?? 0; + _running = content.Running; + _spawnLocationIsHome = content.SpawnLocationIsHome; + // End is unsaved when default; a running spawner re-arms immediately either way (DoTimer clamps). + _end = _running ? content.End ?? Core.Now : Core.Now; + _spawnPositionMode = content.SpawnPositionMode ?? SpawnPositionMode.Automatic; + _maxSpawnAttempts = content.MaxSpawnAttempts ?? DefaultMaxSpawnAttempts; + + AdoptEntries(content.Entries ?? []); + } + private void Deserialize(IGenericReader reader, int version) { _guid = reader.ReadGuid(); _returnOnDeactivate = reader.ReadBool(); var count = reader.ReadInt(); - _entries = new List(count); + var entries = new List(count); for (var i = 0; i < count; ++i) { var entry = new SpawnerEntry(this); entry.Deserialize(reader); - _entries.Add(entry); + entries.Add(entry); } + AdoptEntries(entries); + _walkingRange = reader.ReadInt(); _wayPoint = reader.ReadEntity(); _group = reader.ReadBool(); diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 750a08257..3a78f3b4a 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -40,7 +40,7 @@ public enum SpawnPositionMode : byte Abandoned = 3 } -[SerializationGenerator(12, false)] +[SerializationGenerator(13, false)] public abstract partial class BaseSpawner : Item, ISpawner { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseSpawner)); @@ -61,15 +61,11 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; - [SerializedIgnoreDupe] - [SerializableField(2, setter: "private")] - private List _entries; - private int _walkingRange = -1; private bool ShouldSerializeWayPoint() => _wayPoint != null; - [SerializableField(4)] + [SerializableField(3)] [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; @@ -77,7 +73,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeGroup() => _group; [InvalidateProperties] - [SerializableField(5)] + [SerializableField(4)] [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; @@ -87,7 +83,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] - [SerializableField(6)] + [SerializableField(5)] [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; @@ -97,7 +93,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] - [SerializableField(7)] + [SerializableField(6)] [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; @@ -105,7 +101,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] - [SerializableField(9)] + [SerializableField(8)] [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -126,14 +122,14 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] - [SerializableField(11)] + [SerializableField(10)] [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; private bool ShouldSerializeEnd() => _end != default; - [SerializableField(12)] + [SerializableField(11)] [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; @@ -144,7 +140,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; - [SerializableField(13)] + [SerializableField(12)] [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -158,7 +154,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; - [SerializableField(14)] + [SerializableField(13)] [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; @@ -300,7 +296,7 @@ public abstract partial class BaseSpawner : Item, ISpawner public Dictionary Spawned { get; private set; } [CommandProperty(AccessLevel.Developer)] - [SerializableProperty(3, nameof(_walkingRange))] + [SerializableProperty(2, nameof(_walkingRange))] public int WalkingRange { get => _walkingRange > 0 ? _walkingRange : HomeRange; @@ -312,7 +308,7 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableField(8, fieldChanged: nameof(OnCountChanged))] + [SerializableField(7, fieldChanged: nameof(OnCountChanged))] [SerializedCommandProperty(AccessLevel.Developer)] [InvalidateProperties] private int _count; @@ -329,7 +325,7 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(10)] + [SerializableProperty(9)] [CommandProperty(AccessLevel.Developer)] public bool Running { @@ -356,10 +352,10 @@ public abstract partial class BaseSpawner : Item, ISpawner get => _running && _timer?.Running == true ? End - Core.Now : TimeSpan.Zero; set { - if (!_running && Entries.Count > 0) + if (BeginStart()) { - _running = true; DoTimer(value); + OnStarted(); } } } @@ -641,20 +637,7 @@ public abstract partial class BaseSpawner : Item, ISpawner { newSpawner._guid = Guid.NewGuid(); newSpawner.Spawned = new Dictionary(); - newSpawner.Entries = []; - - for (var i = 0; i < Entries.Count; i++) - { - var entry = Entries[i]; - newSpawner.AddEntry( - entry.SpawnedName, - entry.SpawnedProbability, - entry.SpawnedMaxCount, - false, - entry.Properties, - entry.Parameters - ); - } + CopyEntriesTo(newSpawner); } } @@ -698,25 +681,6 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - public SpawnerEntry AddEntry( - string creaturename, - int probability = 100, - int amount = 1, - bool dotimer = true, - string properties = null, - string parameters = null - ) - { - var entry = new SpawnerEntry(this, creaturename, probability, amount, properties, parameters); - AddToEntries(entry); - if (dotimer) - { - DoTimer(TimeSpan.FromSeconds(1)); - } - - return entry; - } - public void InitSpawn(int amount, TimeSpan minDelay, TimeSpan maxDelay, int team = 0, Rectangle3D spawnBounds = default) { Visible = false; @@ -737,7 +701,7 @@ public abstract partial class BaseSpawner : Item, ISpawner HomeRange = 4; } - Entries = []; + ClearEntriesCore(); Spawned = new Dictionary(); DoTimer(TimeSpan.FromSeconds(1)); @@ -804,26 +768,42 @@ public abstract partial class BaseSpawner : Item, ISpawner public void Start() { - if (!_running && Entries.Count > 0) + if (BeginStart()) { - _running = true; DoTimer(); + OnStarted(); } } + /// Guards and flips . Callers arm the timer and fire . + private bool BeginStart() + { + if (_running || Entries.Count == 0) + { + return false; + } + + _running = true; + return true; + } + public void Stop() { + var wasRunning = _running; _timer?.Stop(); _running = false; + if (wasRunning) + { + OnStopped(); + } } public void Defrag() { - Entries ??= []; - - for (var i = 0; i < Entries.Count; ++i) + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) { - Entries[i].Defrag(this); + entries[i].Defrag(this); } } @@ -867,16 +847,18 @@ public abstract partial class BaseSpawner : Item, ISpawner { Defrag(); - if (Entries.Count <= 0 || IsFull) + var entries = EntrySpan; + if (entries.Length <= 0 || IsFull) { return; } var probsum = 0; - foreach (var spawnerEntry in Entries) + for (var i = 0; i < entries.Length; i++) { - if (!spawnerEntry.IsFull) + var spawnerEntry = entries[i]; + if (!spawnerEntry.IsFull && !spawnerEntry.Disabled) { probsum += spawnerEntry.SpawnedProbability; } @@ -889,10 +871,10 @@ public abstract partial class BaseSpawner : Item, ISpawner var rand = Utility.RandomMinMax(1, probsum); - for (var i = 0; i < Entries.Count; i++) + for (var i = 0; i < entries.Length; i++) { - var entry = Entries[i]; - if (entry.IsFull) + var entry = entries[i]; + if (entry.IsFull || entry.Disabled) { continue; } @@ -1010,6 +992,11 @@ public abstract partial class BaseSpawner : Item, ISpawner return false; } + if (!OnBeforeSpawn(entry)) + { + return false; + } + try { IEntity entity = null; @@ -1095,6 +1082,11 @@ public abstract partial class BaseSpawner : Item, ISpawner } } + if (entity is ISpawnable configured) + { + OnConfigureSpawned(entry, configured); + } + if (entity is Mobile m) { Spawned.Add(m, entry); @@ -1102,7 +1094,7 @@ public abstract partial class BaseSpawner : Item, ISpawner // var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map); - var spawnLocation = GetSpawnPosition(m, map); + var spawnLocation = GetSpawnPosition(entry, m, map); m.OnBeforeSpawn(spawnLocation, map); m.MoveToWorld(spawnLocation, map); @@ -1133,13 +1125,14 @@ public abstract partial class BaseSpawner : Item, ISpawner m.Spawner = this; m.OnAfterSpawn(); + OnSpawned(entry, m); } else if (entity is Item item) { Spawned.Add(item, entry); entry.AddToSpawned(item); - var loc = GetSpawnPosition(item, map); + var loc = GetSpawnPosition(entry, item, map); item.OnBeforeSpawn(loc, map); @@ -1147,6 +1140,7 @@ public abstract partial class BaseSpawner : Item, ISpawner item.Spawner = this; item.OnAfterSpawn(); + OnSpawned(entry, item); } else { @@ -1221,27 +1215,6 @@ public abstract partial class BaseSpawner : Item, ISpawner return entry.Spawned.Count; } - public void RemoveEntry(SpawnerEntry entry) - { - Defrag(); - - for (var i = entry.Spawned.Count - 1; i >= 0; i--) - { - var e = entry.Spawned[i]; - entry.Spawned.RemoveAt(i); - e?.Delete(); - } - - Entries.Remove(entry); - - if (_running && !IsFull && _timer?.Running != true) - { - DoTimer(); - } - - InvalidateProperties(); - } - public void RemoveSpawn(int index) // Entry { if (index >= 0 && index < Entries.Count) @@ -1269,9 +1242,10 @@ public abstract partial class BaseSpawner : Item, ISpawner { Defrag(); - for (var i = 0; i < Entries.Count; i++) + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) { - var entry = Entries[i]; + var entry = entries[i]; for (var j = entry.Spawned.Count - 1; j >= 0; j--) { @@ -1327,18 +1301,6 @@ public abstract partial class BaseSpawner : Item, ISpawner 256 ); } - - Spawned = new Dictionary(); - - foreach (var entry in Entries) - { - foreach (var spawned in entry.Spawned) - { - Spawned.Add(spawned, entry); - } - } - - DoTimer(_end - Core.Now); } private class InternalTimer : Timer diff --git a/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs index 067a7f762..4efc99585 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs @@ -105,8 +105,10 @@ public class EditSpawnCommand : BaseCommand public static void UpdateSpawner(BaseSpawner spawner, string name, string arguments, string properties, string find = null) { - foreach (var entry in spawner.Entries) + for (var i = 0; i < spawner.Entries.Count; i++) { + var entry = spawner.Entries[i]; + // TODO: Should cache spawn type on the entry if (!entry.SpawnedName.InsensitiveEquals(name)) { diff --git a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs index dc5ef63f4..e6bc92808 100644 --- a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs +++ b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs @@ -74,11 +74,6 @@ public abstract record SpawnerDto [JsonPropertyOrder(9)] public int WalkingRange { get; init; } - [JsonPropertyName("entries")] - [JsonPropertyOrder(10)] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public List Entries { get; init; } - [JsonPropertyName("spawnLocationIsHome")] [JsonPropertyOrder(11)] public bool SpawnLocationIsHome { get; init; } @@ -97,6 +92,10 @@ public abstract record SpawnerDto [JsonPropertyOrder(8)] public int HomeRange { get; init; } = -1; + /// The entries carried by the concrete record, in its own entry type. Never serialized directly. + [JsonIgnore] + public abstract IReadOnlyList EntryView { get; } + /// Constructs the empty concrete spawner Item for this DTO. protected abstract BaseSpawner CreateEmpty(); @@ -125,6 +124,14 @@ public sealed record SpawnerDataDto : SpawnerDto [JsonPropertyOrder(8)] public Rectangle3D SpawnBounds { get; init; } + [JsonPropertyName("entries")] + [JsonPropertyOrder(10)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new Spawner(); public override BaseSpawner ToSpawner() @@ -154,6 +161,14 @@ public sealed record RegionSpawnerDto : SpawnerDto [JsonPropertyOrder(8)] public string Region { get; init; } + [JsonPropertyName("entries")] + [JsonPropertyOrder(10)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new RegionSpawner(); public override BaseSpawner ToSpawner() @@ -191,6 +206,14 @@ public sealed record ProximitySpawnerDto : SpawnerDto [JsonPropertyOrder(16)] public bool Instant { get; init; } + [JsonPropertyName("entries")] + [JsonPropertyOrder(10)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new ProximitySpawner(); public override BaseSpawner ToSpawner() diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs index 219e91943..d71676815 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs @@ -31,7 +31,7 @@ public partial class ProximitySpawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs index 3d9614247..d6c343071 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs @@ -29,7 +29,7 @@ public partial class RegionSpawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs index a291a4854..4b9e9f495 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs @@ -31,7 +31,7 @@ public partial class Spawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index db25cab55..184558e79 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; using ModernUO.Serialization; namespace Server.Engines.Spawners; -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Spawner : BaseSpawner { /// @@ -33,6 +35,11 @@ public partial class Spawner : BaseSpawner } } + // Owned by the concrete class so subclasses can store their own entry type; null until the first entry. + [SerializedIgnoreDupe] + [SerializableField(2, getter: "protected", setter: "private")] + private List _entryList; + [Constructible(AccessLevel.Developer)] public Spawner() { @@ -63,8 +70,73 @@ public partial class Spawner : BaseSpawner protected override ReadOnlySpan GetAllSpawnBounds() => new(ref _spawnBounds); + public override IReadOnlyList Entries => _entryList ?? (IReadOnlyList)Array.Empty(); + + protected override ReadOnlySpan EntrySpan => CollectionsMarshal.AsSpan(_entryList); + + protected override SpawnerEntry CreateEntry( + string name, + int probability, + int maxCount, + string properties, + string parameters + ) => new(this, name, probability, maxCount, properties, parameters); + + protected override void AddEntryCore(SpawnerEntry entry) + { + EntryList ??= []; + AddToEntryList(entry); + } + + protected override bool RemoveEntryCore(SpawnerEntry entry) + { + if (_entryList?.Contains(entry) != true) + { + return false; + } + + RemoveFromEntryList(entry); + return true; + } + + protected override void ClearEntriesCore() + { + if (_entryList?.Count > 0) + { + ClearEntryList(); + } + } + + protected override void AdoptEntries(IReadOnlyList entries) + { + if (entries.Count == 0) + { + EntryList = null; + return; + } + + // Copy, never alias the caller's list. + var list = new List(entries); + for (var i = 0; i < list.Count; i++) + { + list[i].SetParent(this); + } + + EntryList = list; + } + private void MigrateFrom(V0Content content) { - // V0 had no fields in Spawner, new v1 field _useSpiralScan defaults to false + // v0 had no fields. } + + private void MigrateFrom(V1Content content) + { + _useSpiralScan = content.UseSpiralScan; + _spawnBounds = content.SpawnBounds ?? default; + // _entryList was already adopted by BaseSpawner.MigrateFrom(V12Content). + } + + [AfterDeserialization] + private void AfterDeserialization() => RebuildSpawned(); } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs index 787ef9ab4..ee5c44586 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs @@ -447,8 +447,9 @@ public class SpawnerControllerGump : DynamicGump private static bool SearchSpawnerCreatures(BaseSpawner spawner, string searchPattern) { - foreach (var entry in spawner.Entries) + for (var i = 0; i < spawner.Entries.Count; i++) { + var entry = spawner.Entries[i]; if (entry.SpawnedName?.InsensitiveContains(searchPattern) == true) { return true; @@ -486,17 +487,9 @@ public class SpawnerControllerGump : DynamicGump public static void CopyEntry(BaseSpawner spawner, BaseSpawner target) { - if (spawner.Entries?.Count > 0) + if (spawner.Entries.Count > 0) { - target.Entries?.Clear(); - - for (var i = 0; i < spawner.Entries.Count; i++) - { - var item = spawner.Entries[i]; - var targetEntry = target.AddEntry(item.SpawnedName, item.SpawnedProbability, item.SpawnedMaxCount); - targetEntry.Properties = item.Properties; - targetEntry.Parameters = item.Parameters; - } + spawner.CopyEntriesTo(target); } } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs index d364b3916..524da1b6f 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs @@ -5,7 +5,7 @@ using Server.Json; namespace Server.Engines.Spawners; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class SpawnerEntry { [DirtyTrackingEntity] @@ -36,6 +36,48 @@ public partial class SpawnerEntry [SerializableField(5)] private List _spawned; + private bool ShouldSerializeDisabled() => _disabled; + + /// + /// Locked entries are skipped by weighted selection; live spawns are untouched. + /// Stored inverted so the common (enabled) case writes nothing. + /// + [SaveFlag(nameof(ShouldSerializeDisabled))] + [SerializableField(6)] + [SerializedJsonPropertyName("disabled")] + private bool _disabled; + + [JsonIgnore] + public bool Enabled + { + get => !_disabled; + set => Disabled = !value; + } + + /// + /// The spawner that owns this entry. Derived entry types declare it as their + /// [DirtyTrackingEntity]; the generator only inspects the type it is generating. + /// + protected BaseSpawner Parent => _parent; + + /// Re-parents this entry. Public so out-of-tree owners can call it from AdoptEntries. + public void SetParent(BaseSpawner parent) + { + _parent = parent; + _spawned ??= []; + } + + private void MigrateFrom(V1Content content) + { + _spawnedName = content.SpawnedName; + _spawnedProbability = content.SpawnedProbability; + _spawnedMaxCount = content.SpawnedMaxCount; + _properties = content.Properties; + _parameters = content.Parameters; + _spawned = content.Spawned ?? []; + _disabled = false; + } + public SpawnerEntry(BaseSpawner parent) { _parent = parent; diff --git a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs index e22782228..e7edd6247 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs @@ -63,7 +63,18 @@ public class SpawnerGump : Gump ); // Unexpand } - AddButton(38, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete + AddButton(46, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete + + if (entry != null) + { + AddButton( + 22, + 22 * i + 21 + offset, + entry.Disabled ? 0xD2 : 0xD3, + entry.Disabled ? 0xD3 : 0xD2, + GetButtonID(3, i) + ); // Enabled toggle (checked = enabled) + } AddImageTiled(71, 22 * i + 20 + offset, 161, 23, 0xA40); // creature text box AddImageTiled(72, 22 * i + 21 + offset, 159, 21, 0xBBC); // creature text box @@ -106,7 +117,7 @@ public class SpawnerGump : Gump 22 * i + 21 + offset, 156, 21, - (flags & EntryFlags.InvalidType) != 0 ? 33 : 0, + (flags & EntryFlags.InvalidType) != 0 ? 33 : entry?.Disabled == true ? 0x3B2 : 0, textIndex, name ); @@ -160,8 +171,9 @@ public class SpawnerGump : Gump var totalSpawned = 0; var totalWeight = 0; - foreach (var spawnerEntry in _spawner.Entries) + for (var i = 0; i < _spawner.Entries.Count; i++) { + var spawnerEntry = _spawner.Entries[i]; totalSpawned += spawner.CountSpawns(spawnerEntry); totalWeight += spawnerEntry.SpawnedProbability; } @@ -419,12 +431,24 @@ public class SpawnerGump : Gump } } + CreateArray(info, state.Mobile, _spawner); + break; + } + case 3: // Enable/disable entry + { + var entryIndex = index + _page * 13; + if (entryIndex >= 0 && entryIndex < _spawner.Entries.Count) + { + var entry = _spawner.Entries[entryIndex]; + entry.Disabled = !entry.Disabled; + } + CreateArray(info, state.Mobile, _spawner); break; } } - if (_entry != null && _spawner.Entries?.Contains(_entry) == true) + if (_entry != null && HasEntry(_spawner, _entry)) { state.Mobile.SendGump(new SpawnerGump(_spawner, _entry, _page)); } @@ -433,4 +457,19 @@ public class SpawnerGump : Gump state.Mobile.SendGump(new SpawnerGump(_spawner, null, _page)); } } + + private static bool HasEntry(BaseSpawner spawner, SpawnerEntry entry) + { + var entries = spawner.Entries; + + for (var i = 0; i < entries.Count; i++) + { + if (entries[i] == entry) + { + return true; + } + } + + return false; + } } diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json new file mode 100644 index 000000000..eab4d5279 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json @@ -0,0 +1,113 @@ +{ + "version": 13, + "type": "Server.Engines.Spawners.BaseSpawner", + "properties": [ + { + "name": "Guid", + "type": "System.Guid", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ReturnOnDeactivate", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WalkingRange", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WayPoint", + "type": "Server.Items.WayPoint", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Group", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinDelay", + "type": "System.TimeSpan", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MaxDelay", + "type": "System.TimeSpan", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Count", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Team", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Running", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnLocationIsHome", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "End", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnPositionMode", + "type": "Server.Engines.Spawners.SpawnPositionMode", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "MaxSpawnAttempts", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json new file mode 100644 index 000000000..8127420e2 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Engines.Spawners.Spawner", + "properties": [ + { + "name": "UseSpiralScan", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnBounds", + "type": "Server.Rectangle3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect3D" + ] + }, + { + "name": "EntryList", + "type": "System.Collections.Generic.List\u003CServer.Engines.Spawners.SpawnerEntry\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Engines.Spawners.SpawnerEntry", + "RawSerializableMigrationRule", + "DeserializationRequiresParent" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json new file mode 100644 index 000000000..481b3b243 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json @@ -0,0 +1,65 @@ +{ + "version": 2, + "type": "Server.Engines.Spawners.SpawnerEntry", + "properties": [ + { + "name": "SpawnedName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnedProbability", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnedMaxCount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Properties", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Parameters", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Spawned", + "type": "System.Collections.Generic.List\u003CServer.ISpawnable\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.ISpawnable", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Disabled", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index b7e893f84..077e47174 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3453,6 +3453,11 @@ namespace Server.Mobiles public override void OnDeath(Container c) { + if (Spawner is BaseSpawner spawner) + { + spawner.NotifySpawnedDeath(this, LastKiller); + } + if (IsBonded) { Effects.PlaySound(this, GetDeathSound()); From 4fe5dd0eec5d6451dea6aecdadaa444e9fc87e54 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:30:02 -0700 Subject: [PATCH 18/21] fix(spawners): lock column in the spawner gump, read-only locked entries, aligned totals (#2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #2621, which added a per-entry `Disabled` flag to the spawner gump as a checkbox. The checkbox was placed between the Expand and Delete buttons at x=22, overlapping Expand (5–35), and Delete moved to 46, overlapping the creature text box at 71. - **Lock column on the far left.** Each entry row gets a toggle at x 5–25: padlock `0x82C` when the entry is disabled (press to unlock), green orb `0x2C88/0x2C89` when enabled (press to lock). The client's gump art has only closed padlocks (`0x82C`, `0x0020`), so the orb marks the unlocked state; XmlSpawner uses the same padlock paired with a blue gem. - **Expand and Delete are adjacent again** (28 / 61, the original 33px pitch), and everything to the right shifts 23px: creature/#/Max/Prb boxes, headers, the totals row, page arrows, Save/Cancel. The gump is now 369 wide (was 346). Params/Props boxes widen to match. - **Locked entries are read-only.** Name, max, probability, and Params/Props (when expanded) render as `AddLabelCropped` on a grey tile (`0x23F4`) instead of `AddTextEntry`. There is no read-only text entry in the UO gump protocol, so omitting the entry is the only way to make the field truly non-editable. `CreateArray` already `continue`s on a null type entry, so Save leaves locked entries untouched. - **Totals row aligned under its columns.** Spawned under `#`, a new total max under `Max`, total weight under `Prb`. Max and weight exclude locked entries, matching `BaseSpawner`, which skips `Disabled` entries when rolling. ## Test plan - [x] `[props` a spawner with several entries: lock column is on the left, Expand/Delete sit side by side, no overlaps. - [x] Lock an entry: padlock shows, fields go grey and cannot be typed into; unlock: orb shows, fields are editable again. - [x] Edit an unlocked entry, lock another, Save: unlocked edits persist, locked entry keeps its values. - [x] Expand a locked entry: Params/Props are grey and read-only. - [x] Totals sit under `#` / `Max` / `Prb`; locking an entry drops it from the max and weight totals. --- .../UOContent/Engines/Spawners/SpawnerGump.cs | 147 ++++++++++-------- 1 file changed, 78 insertions(+), 69 deletions(-) diff --git a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs index e7edd6247..ac14a9c5d 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs @@ -19,12 +19,12 @@ public class SpawnerGump : Gump AddPage(0); - AddBackground(0, 0, 346, 400 + (_entry != null ? 44 : 0), 5054); - AddAlphaRegion(0, 0, 346, 400 + (_entry != null ? 44 : 0)); + AddBackground(0, 0, 369, 400 + (_entry != null ? 44 : 0), 5054); + AddAlphaRegion(0, 0, 369, 400 + (_entry != null ? 44 : 0)); - AddHtml(240, 1, 250, 20, "#"); - AddHtml(271, 1, 250, 20, "Max"); - AddHtml(311, 1, 250, 20, "Prb"); + AddHtml(263, 1, 250, 20, "#"); + AddHtml(294, 1, 250, 20, "Max"); + AddHtml(334, 1, 250, 20, "Prb"); // AddLabel( 95, 1, 0, "Creatures List" ); @@ -45,7 +45,7 @@ public class SpawnerGump : Gump if (entry == null || _entry != entry) { AddButton( - 5, + 28, 22 * i + 21 + offset, entry != null ? 0xFBA : 0xFA5, entry != null ? 0xFBC : 0xFA7, @@ -55,7 +55,7 @@ public class SpawnerGump : Gump else { AddButton( - 5, + 28, 22 * i + 21 + offset, 0xFBB, 0xFBC, @@ -63,30 +63,35 @@ public class SpawnerGump : Gump ); // Unexpand } - AddButton(46, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete + AddButton(61, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete if (entry != null) { - AddButton( - 22, - 22 * i + 21 + offset, - entry.Disabled ? 0xD2 : 0xD3, - entry.Disabled ? 0xD3 : 0xD2, - GetButtonID(3, i) - ); // Enabled toggle (checked = enabled) + if (entry.Disabled) + { + AddButton(9, 22 * i + 24 + offset, 0x82C, 0x82C, GetButtonID(3, i)); // Locked, press to unlock + } + else + { + AddButton(7, 22 * i + 24 + offset, 0x2C88, 0x2C89, GetButtonID(3, i)); // Unlocked, press to lock + } } - AddImageTiled(71, 22 * i + 20 + offset, 161, 23, 0xA40); // creature text box - AddImageTiled(72, 22 * i + 21 + offset, 159, 21, 0xBBC); // creature text box + // Locked entries render on a grey tile as labels instead of text entries, so they can't be edited + var locked = entry?.Disabled == true; + var fieldTile = locked ? 0x23F4 : 0xBBC; - AddImageTiled(235, 22 * i + 20 + offset, 35, 23, 0xA40); // count html label - AddImageTiled(236, 22 * i + 21 + offset, 33, 21, 0xE14); // count html label + AddImageTiled(94, 22 * i + 20 + offset, 161, 23, 0xA40); // creature text box + AddImageTiled(95, 22 * i + 21 + offset, 159, 21, fieldTile); // creature text box - AddImageTiled(267, 22 * i + 20 + offset, 35, 23, 0xA40); // maxcount text box - AddImageTiled(268, 22 * i + 21 + offset, 33, 21, 0xBBC); // maxcount text box + AddImageTiled(258, 22 * i + 20 + offset, 35, 23, 0xA40); // count html label + AddImageTiled(259, 22 * i + 21 + offset, 33, 21, 0xE14); // count html label - AddImageTiled(305, 22 * i + 20 + offset, 35, 23, 0xA40); // probability text box - AddImageTiled(306, 22 * i + 21 + offset, 33, 21, 0xBBC); // probability text box + AddImageTiled(290, 22 * i + 20 + offset, 35, 23, 0xA40); // maxcount text box + AddImageTiled(291, 22 * i + 21 + offset, 33, 21, fieldTile); // maxcount text box + + AddImageTiled(328, 22 * i + 20 + offset, 35, 23, 0xA40); // probability text box + AddImageTiled(329, 22 * i + 21 + offset, 33, 21, fieldTile); // probability text box string name; string probability; @@ -102,7 +107,7 @@ public class SpawnerGump : Gump var count = spawner.CountSpawns(entry); - AddHtml(235, 22 * i + 20 + offset + 1, 35, 15, $"
{count}/
"); + AddHtml(258, 22 * i + 20 + offset + 1, 35, 15, $"
{count}/
"); } else { @@ -111,47 +116,44 @@ public class SpawnerGump : Gump maxCount = ""; } - // creature - AddTextEntry( - 75, - 22 * i + 21 + offset, - 156, - 21, - (flags & EntryFlags.InvalidType) != 0 ? 33 : entry?.Disabled == true ? 0x3B2 : 0, - textIndex, - name - ); - AddTextEntry(270, 22 * i + 21 + offset, 30, 21, 0, textIndex + 1, maxCount); // max count - AddTextEntry(308, 22 * i + 21 + offset, 30, 21, 0, textIndex + 2, probability); // probability + var nameHue = (flags & EntryFlags.InvalidType) != 0 ? 33 : 0; + + if (locked) + { + AddLabelCropped(98, 22 * i + 21 + offset, 156, 21, nameHue, name); // creature + AddLabelCropped(293, 22 * i + 21 + offset, 30, 21, 0, maxCount); // max count + AddLabelCropped(331, 22 * i + 21 + offset, 30, 21, 0, probability); // probability + } + else + { + AddTextEntry(98, 22 * i + 21 + offset, 156, 21, nameHue, textIndex, name); // creature + AddTextEntry(293, 22 * i + 21 + offset, 30, 21, 0, textIndex + 1, maxCount); // max count + AddTextEntry(331, 22 * i + 21 + offset, 30, 21, 0, textIndex + 2, probability); // probability + } if (entry != null && _entry == entry) { AddLabel(5, 22 * i + 42, 0x384, "Params"); - AddImageTiled(55, 22 * i + 42, 253, 23, 0xA40); // Parameters - AddImageTiled(56, 22 * i + 43, 251, 21, 0xBBC); // Parameters + AddImageTiled(55, 22 * i + 42, 276, 23, 0xA40); // Parameters + AddImageTiled(56, 22 * i + 43, 274, 21, fieldTile); // Parameters AddLabel(5, 22 * i + 64, 0x384, "Props"); - AddImageTiled(55, 22 * i + 64, 253, 23, 0xA40); // Properties - AddImageTiled(56, 22 * i + 65, 251, 21, 0xBBC); // Properties + AddImageTiled(55, 22 * i + 64, 276, 23, 0xA40); // Properties + AddImageTiled(56, 22 * i + 65, 274, 21, fieldTile); // Properties - AddTextEntry( - 59, - 22 * i + 42, - 248, - 21, - (flags & EntryFlags.InvalidParams) != 0 ? 33 : 0, - textIndex + 3, - entry.Parameters - ); // parameters - AddTextEntry( - 59, - 22 * i + 62, - 248, - 21, - (flags & EntryFlags.InvalidProps) != 0 ? 33 : 0, - textIndex + 4, - entry.Properties - ); // properties + var paramsHue = (flags & EntryFlags.InvalidParams) != 0 ? 33 : 0; + var propsHue = (flags & EntryFlags.InvalidProps) != 0 ? 33 : 0; + + if (locked) + { + AddLabelCropped(59, 22 * i + 42, 271, 21, paramsHue, entry.Parameters); // parameters + AddLabelCropped(59, 22 * i + 62, 271, 21, propsHue, entry.Properties); // properties + } + else + { + AddTextEntry(59, 22 * i + 42, 271, 21, paramsHue, textIndex + 3, entry.Parameters); // parameters + AddTextEntry(59, 22 * i + 62, 271, 21, propsHue, textIndex + 4, entry.Properties); // properties + } offset += 44; } @@ -169,17 +171,24 @@ public class SpawnerGump : Gump } var totalSpawned = 0; + var totalMax = 0; var totalWeight = 0; for (var i = 0; i < _spawner.Entries.Count; i++) { var spawnerEntry = _spawner.Entries[i]; totalSpawned += spawner.CountSpawns(spawnerEntry); - totalWeight += spawnerEntry.SpawnedProbability; + + if (!spawnerEntry.Disabled) + { + totalMax += spawnerEntry.SpawnedMaxCount; + totalWeight += spawnerEntry.SpawnedProbability; + } } - AddHtml(232, 308 + offset, 35, 20, Html.Center($"{totalSpawned}", 0xF4F4F4)); - AddHtml(270, 308 + offset, 35, 20, Html.Center($"{totalWeight}", 0xF4F4F4)); + AddHtml(258, 308 + offset, 35, 20, Html.Center($"{totalSpawned}", 0xF4F4F4)); + AddHtml(290, 308 + offset, 35, 20, Html.Center($"{totalMax}", 0xF4F4F4)); + AddHtml(328, 308 + offset, 35, 20, Html.Center($"{totalWeight}", 0xF4F4F4)); AddHtml(5, 1, 161, 20, $"{spawner.Name} ({totalSpawned}/{spawner.Count})"); @@ -198,28 +207,28 @@ public class SpawnerGump : Gump AddButton(90, 369 + offset, 0xFA8, 0xFAA, GetButtonID(1, 4)); AddLabel(123, 369 + offset, 0x384, "Total Respawn"); - AddButton(260, 347 + offset, 0xFB7, 0xFB9, GetButtonID(1, 5)); - AddLabel(293, 347 + offset, 0x384, "Save"); + AddButton(283, 347 + offset, 0xFB7, 0xFB9, GetButtonID(1, 5)); + AddLabel(316, 347 + offset, 0x384, "Save"); - AddButton(260, 369 + offset, 0xFB1, 0xFB3, 0); - AddLabel(293, 369 + offset, 0x384, "Cancel"); + AddButton(283, 369 + offset, 0xFB1, 0xFB3, 0); + AddLabel(316, 369 + offset, 0x384, "Cancel"); if (_page > 0) { - AddButton(200, 308 + offset, 0x15E3, 0x15E7, GetButtonID(1, 0)); + AddButton(223, 308 + offset, 0x15E3, 0x15E7, GetButtonID(1, 0)); } else { - AddImage(200, 308 + offset, 0x25EA); + AddImage(223, 308 + offset, 0x25EA); } if ((_page + 1) * 13 <= _spawner.Entries.Count) { - AddButton(217, 308 + offset, 0x15E1, 0x15E5, GetButtonID(1, 1)); + AddButton(240, 308 + offset, 0x15E1, 0x15E5, GetButtonID(1, 1)); } else { - AddImage(217, 308 + offset, 0x25E6); + AddImage(240, 308 + offset, 0x25E6); } } From 7deb8b082ea3f32b1e7429e17ff5c00aa58070a2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:55:29 -0700 Subject: [PATCH 19/21] fix: Bumps dependencies (#2638) --- Directory.Build.props | 2 +- Projects/Server.Tests/Server.Tests.csproj | 4 ++-- Projects/Server/Server.csproj | 2 +- Projects/UOContent.Tests/UOContent.Tests.csproj | 4 ++-- Projects/UOContent/UOContent.csproj | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index c832f1006..ad8c7515a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -75,7 +75,7 @@ - 3.10.91 + 3.10.94 all diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 4bae245cb..b1462ba37 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,9 +5,9 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index ab982279f..dfef0af46 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,7 +37,7 @@ - + diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index ed096038f..1dc4440ae 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,13 +4,13 @@ Debug;Release;Analyze - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 40919e197..996ec9514 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -41,9 +41,9 @@ false - + - + From 309fcfeb27aa7cc943d44e8c6e17d2ae2e4d687a Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:58:44 -0700 Subject: [PATCH 20/21] feat(skills): SkillEvents.SkillUsed for cross-assembly subscribers; InternalsVisibleTo ModernSpawner.Tests (#2636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two small additive changes that an external content assembly (ModernSpawner) needs, as separable commits. **1. `SkillEvents.SkillUsed`** (`Projects/UOContent/Skills/SkillEvents.cs`, namespace `Server.Misc`): a plain C# event `Action` raised once per skill attempt from each of the four `Mobile_SkillCheck*` handlers, with the handler's own result. Attempts the handler resolves without a roll (too difficult, no challenge) raise too, so a grandmaster's trivial success and a guaranteed combat roll are observable. Not raised when the mobile lacks the skill. Each handler keeps its logic in a private core method and raises on the way out, so there is exactly one raise per attempt and `CheckSkill` itself is unchanged. - **Why a plain event and not a `[GeneratedEvent]`:** generated events are compile-time static dispatch inside the UOContent compilation, so a subscriber in another assembly cannot use `[OnEvent]`. Shape follows `HelpEvents`. - **Why "used", not "gained":** this is the XmlSpawner skill-trigger semantic (it wrapped the same four handlers and passed their result as `success`; its grammar was `Skill[+/-]` for success-only or failure-only). Gains are already observable through the existing skill-change notification on `Mobile`. - **Cost:** one delegate null-check per attempt when nothing is subscribed; no boxing, no closure, no allocation. The handlers sit on the combat swing path. - **Exception contract:** subscriber exceptions propagate, matching `EventSink`/`HelpEvents`; no try/catch by design. **2. `InternalsVisibleTo("ModernSpawner.Tests")`** on `Server.csproj`, beside the existing `Server.Tests`/`UOContent.Tests` entries, so an external test host can seed `Core._now` the way the engine's own test initializers do. Separable; a public test seam on `Core` would serve the same need without naming a downstream assembly. ## Open question The payload is the `Skill` object plus a positional `bool`. A `readonly struct` args type passed `in` would leave room to add `chance` or the target later without breaking subscribers. Happy to change before merge. ## Test plan - [x] `UOContent.Tests`: 4 tests — a rolled attempt raises once with the returned outcome; each short-circuit path (no challenge, too difficult, on both the direct and value-window handlers) raises with the handler's result; a direct `CheckSkill` call does not raise; no subscriber does not throw. Full suite green. - [x] `Server` and `UOContent` build clean with `TreatWarningsAsErrors`. - [ ] CI --- Projects/Server/Server.csproj | 3 + .../Tests/Skills/SkillEventsTests.cs | 124 ++++++++++++++++++ Projects/UOContent/Skills/SkillCheck.cs | 28 ++++ Projects/UOContent/Skills/SkillEvents.cs | 23 ++++ dev-docs/events.md | 11 ++ 5 files changed, 189 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs create mode 100644 Projects/UOContent/Skills/SkillEvents.cs diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index dfef0af46..8ec416218 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -50,5 +50,8 @@ <_Parameter1>UOContent.Tests + + <_Parameter1>ModernSpawner.Tests + diff --git a/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs new file mode 100644 index 000000000..91fba77bc --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs @@ -0,0 +1,124 @@ +using Server; +using Server.Misc; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class SkillEventsTests +{ + private sealed class Recorder + { + public Mobile From; + public Skill Skill; + public bool Success; + public int Calls; + + public void Handle(Mobile from, Skill skill, bool success) + { + From = from; + Skill = skill; + Success = success; + Calls++; + } + } + + [Fact] + public void DirectTarget_RolledAttempt_RaisesOnceWithTheReturnedOutcome() + { + var from = new Mobile(); + var skill = from.Skills[SkillName.Mining]; + var recorder = new Recorder(); + + SkillEvents.SkillUsed += recorder.Handle; + try + { + var rolled = SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, 0.5); + Assert.Equal(1, recorder.Calls); + Assert.Same(from, recorder.From); + Assert.Same(skill, recorder.Skill); + Assert.Equal(rolled, recorder.Success); + + Assert.False(SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, 0.0)); + Assert.Equal(2, recorder.Calls); + Assert.False(recorder.Success); + } + finally + { + SkillEvents.SkillUsed -= recorder.Handle; + from.Delete(); + } + } + + [Fact] + public void ShortCircuits_StillRaise_WithTheHandlerOutcome() + { + var from = new Mobile(); + var recorder = new Recorder(); + + SkillEvents.SkillUsed += recorder.Handle; + try + { + Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0)); + Assert.Equal(1, recorder.Calls); + Assert.True(recorder.Success); + + Assert.False(SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, -0.1)); + Assert.Equal(2, recorder.Calls); + Assert.False(recorder.Success); + + Assert.False(SkillCheck.Mobile_SkillCheckLocation(from, SkillName.Mining, 50.0, 100.0)); + Assert.Equal(3, recorder.Calls); + Assert.False(recorder.Success); + + Assert.True(SkillCheck.Mobile_SkillCheckTarget(from, SkillName.Mining, null, 0.0, 0.0)); + Assert.Equal(4, recorder.Calls); + Assert.True(recorder.Success); + } + finally + { + SkillEvents.SkillUsed -= recorder.Handle; + from.Delete(); + } + } + + [Fact] + public void CheckSkill_Direct_DoesNotRaise() + { + var from = new Mobile(); + var skill = from.Skills[SkillName.Mining]; + var recorder = new Recorder(); + + SkillEvents.SkillUsed += recorder.Handle; + try + { + SkillCheck.CheckSkill(from, skill, null, 1.0); + Assert.Equal(0, recorder.Calls); + } + finally + { + SkillEvents.SkillUsed -= recorder.Handle; + from.Delete(); + } + } + + [Fact] + public void NoSubscriber_DoesNotThrow() + { + var from = new Mobile(); + var recorder = new Recorder(); + + try + { + SkillEvents.SkillUsed += recorder.Handle; + SkillEvents.SkillUsed -= recorder.Handle; + + Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0)); + Assert.Equal(0, recorder.Calls); + } + finally + { + from.Delete(); + } + } +} diff --git a/Projects/UOContent/Skills/SkillCheck.cs b/Projects/UOContent/Skills/SkillCheck.cs index 5913b0a7f..59f8ebb6a 100644 --- a/Projects/UOContent/Skills/SkillCheck.cs +++ b/Projects/UOContent/Skills/SkillCheck.cs @@ -48,6 +48,13 @@ public static class SkillCheck return false; } + var success = CheckLocation(from, skill, minSkill, maxSkill); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckLocation(Mobile from, Skill skill, double minSkill, double maxSkill) + { var value = skill.Value; if (value < minSkill) @@ -76,6 +83,13 @@ public static class SkillCheck return false; } + var success = CheckDirectLocation(from, skill, chance); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckDirectLocation(Mobile from, Skill skill, double chance) + { if (chance < 0.0) { return false; // Too difficult @@ -156,6 +170,13 @@ public static class SkillCheck return false; } + var success = CheckTarget(from, skill, target, minSkill, maxSkill); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckTarget(Mobile from, Skill skill, object target, double minSkill, double maxSkill) + { var value = skill.Value; if (value < minSkill) @@ -182,6 +203,13 @@ public static class SkillCheck return false; } + var success = CheckDirectTarget(from, skill, target, chance); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckDirectTarget(Mobile from, Skill skill, object target, double chance) + { if (chance < 0.0) { return false; // Too difficult diff --git a/Projects/UOContent/Skills/SkillEvents.cs b/Projects/UOContent/Skills/SkillEvents.cs new file mode 100644 index 000000000..dec624c45 --- /dev/null +++ b/Projects/UOContent/Skills/SkillEvents.cs @@ -0,0 +1,23 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Server.Misc; + +/// +/// Skill system events. Plain C# events so other assemblies can subscribe; generated events cannot be +/// subscribed across assemblies. +/// +public static class SkillEvents +{ + /// + /// Raised once per skill attempt from the four Mobile_SkillCheck* handlers with the attempt's + /// outcome, including attempts the handler resolves without a roll (too difficult, no challenge). + /// Not raised when the mobile lacks the skill. Fires for every , including + /// creatures. Subscribers must not block or allocate. + /// + public static event Action SkillUsed; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void InvokeSkillUsed(Mobile from, Skill skill, bool success) => + SkillUsed?.Invoke(from, skill, success); +} diff --git a/dev-docs/events.md b/dev-docs/events.md index 2fcbeda68..589860db6 100644 --- a/dev-docs/events.md +++ b/dev-docs/events.md @@ -242,6 +242,17 @@ public static void HandlePlayerLogin(PlayerMobile player) --- +## Static Content Events + +Generated events dispatch statically inside `UOContent`; content that other assemblies must observe exposes a +plain `static event` instead (shape: `Projects/UOContent/Engines/Help/HelpEvents.cs`). + +- `SkillEvents.SkillUsed` -- `Action`, raised once per skill attempt from the + four `Mobile_SkillCheck*` handlers with the attempt's outcome, including attempts resolved without a roll + (too difficult, no challenge). Not raised when the mobile lacks the skill. Fires for every `Mobile`. + +--- + ## Event Args Pooling Pattern Some EventArgs use object pooling to avoid allocation in hot paths: From d16166591ca33aec6526774a1ad1501a99cccc16 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:18:37 -0700 Subject: [PATCH 21/21] fix(network): stop sending once a disconnect is handed to the socket (#2637) ## Problem When a client's send buffer fills, the `send buffer exhausted` warning repeats for every packet, every tick, until the NetState is finally disposed. Before the io_uring transport an overflow produced one message plus the disconnect line. ## Root cause Since #2315, `NetState.Disconnect()` only queues. The NetState keeps running, the Mobile stays attached, and in `Slice()` the queued disconnect becomes `RingSocket.Disconnect()`, which sees buffered or in-flight sends and merely sets `DisconnectPending` while the transport drains. Nothing stopped game logic from writing into that buffer afterwards, so: - every broadcast to that player still reached `Send()`, hit the full buffer, and re-reported exhaustion (made visible by #2551); - refills kept `ReadableBytes` above zero, so the graceful drain could never finish, and `DataSent` completions kept pushing the alive check out. A slow-but-acking client could keep a "disconnected" session attached indefinitely. Two more sources of the same warning surfaced during the analysis: `Dispose()` sets `_running = false` before nulling `Mobile.NetState`, and the setter's bank-close / target-cancel packets then reported `0 writable`; and when the socket takes the immediate-close branch (nothing in flight) `Connected` drops without `DisconnectPending`, leaving a one-tick window that also re-reported. ## Changes - `CannotSendPackets()` refuses once the socket is `DisconnectPending` or no longer `Connected`. Sends between `Disconnect()` and the `Slice()` handoff are still delivered (kicks with a message, the play-server ack). - `SendBufferExhausted()` reports once per disconnect and keeps the first reason. - `Send()` is silent while closing instead of reporting exhaustion for a socket that is going away. - `_nextAliveCheck` is seeded from a real tick (the zero default suppressed the alive sweep on hosts whose counter starts negative). - `CancelAllTrades()` had its null guard inverted since 547c2ea0f (#2603) and never cancelled anything. ## Does the gate cut off the graceful flush? No. The gate only refuses writes made after `Slice()` has handed the disconnect to the socket. Everything committed before that point is drained by the transport, which then sends FIN. This matches the pre-io_uring lifecycle: `Disconnect()` cleared `_running` at once, and the next `Slice()` made its final `Flush()` and then closed the socket in `Dispose()`. In both worlds the send window after `Disconnect()` ends at the next network slice; the old one made a single flush attempt, the new one drains everything buffered. Checked flows: - **Login gateway.** `PlayServer` sends the 0x8C ack, and the parser queues `Disconnect()` in the same `HandleReceive` call. Both happen before the handoff, so the ack is delivered and FIN follows. ClassicUO's `HandleRelayServerPacket` disconnects and opens a fresh connection before sending the seed and second login, so the game login is a new NetState. The `LoginServer_ServerSelectAck` "CUO/Orion do not reconnect" fallback (#489, 2021) resets the parser state and returns without parsing or replying, and no further receive is posted once the disconnect is pending, so it sends nothing either way. Orion likewise opens a separate game socket before closing the login one. - **Login rejections, character create/select/delete errors, duplicate-packet guards.** Each sends its rejection first and calls `Disconnect()` in the same handler. - **Kicks and bans** (`[kick`, `[ban`, AdminGump, ClientGump, ClientVerification, AssistantHandler, lockdown). The message is sent first; delayed variants fire `Disconnect()` from a timer with nothing sent afterwards. - **Main loop order.** Timers run before `NetState.Slice()`, packet handlers run inside it before the flush, and `LoopContext` tasks run after it; in every case a send that precedes `Disconnect()` reaches the buffer before the next handoff. `Send_BeforeDisconnect_IsDeliveredThenPeerSeesEof` and `Send_LargeBeforeDisconnect_IsFullyDrainedThenPeerSeesEof` read the bytes back from the peer socket after the handoff (the latter 192 KB across several send completions, past the loopback kernel buffers) and then wait for the FIN, proving delivery and clean close with the gate in place. ## Tests Seven new tests in `NetStateDisconnectTests` over real loopback sockets: force-close after the drain deadline, delivery then EOF for small and multi-completion sends before `Disconnect()`, send dropped after the handoff, send dropped after an immediate close, exhaustion reported once with the first reason kept, and trades cancelled on disconnect. `MockAccount` promoted to a shared test helper. Server.Tests 876 passed, UOContent.Tests 1048 passed against the published 1.0.11. ## Drain deadline and IORingGroup 1.0.11 A socket handed a disconnect drains what is buffered and closes once the peer has acknowledged it. Send completions keep `NextActivityCheck` moving, so a slow but acking peer could hold a closing socket open indefinitely. A deadline (`DrainTimeoutMs`, 10 s) is now armed at the handoff, or on first sight of a transport-initiated drain in `CheckAlive`, and force-closes when it passes, independent of the inactivity check. That force-close is only safe with IORingGroup 1.0.11 (modernuo/IORingGroup#12), which this PR bumps to. Before it, `DisconnectImmediate` released pooled buffers while recv/send operations could still be in flight, a failed send stranded the socket forever, and a recv completion could be delivered after its buffer was released. 1.0.11 retires every outstanding operation before release, aborts on a failed send, and holds buffers until the pass after the `Disconnected` event. --- Projects/Server.Tests/Helpers/MockAccount.cs | 43 +++ .../Helpers/PacketTestUtilities.cs | 9 +- .../Tests/Maps/ClientEnumeratorTests.cs | 35 --- .../Tests/Network/NetStateDisconnectTests.cs | 272 ++++++++++++++++++ .../Network/NetState/NetState.Network.cs | 11 + Projects/Server/Network/NetState/NetState.cs | 71 +++-- .../Server/Network/Packets/OutgoingPackets.cs | 8 +- Projects/Server/Server.csproj | 2 +- 8 files changed, 393 insertions(+), 58 deletions(-) create mode 100644 Projects/Server.Tests/Helpers/MockAccount.cs create mode 100644 Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs diff --git a/Projects/Server.Tests/Helpers/MockAccount.cs b/Projects/Server.Tests/Helpers/MockAccount.cs new file mode 100644 index 000000000..8d8973d86 --- /dev/null +++ b/Projects/Server.Tests/Helpers/MockAccount.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Server.Accounting; + +namespace Server.Tests.Network; + +/// +/// Minimal IAccount so a test NetState looks authenticated. +/// +public class MockAccount : IAccount +{ + public int TotalGold { get; } + public int TotalPlat { get; } + public bool DepositGold(int amount) => throw new NotImplementedException(); + public bool DepositPlat(int amount) => throw new NotImplementedException(); + public bool WithdrawGold(int amount) => throw new NotImplementedException(); + public bool WithdrawPlat(int amount) => throw new NotImplementedException(); + public long GetTotalGold() => throw new NotImplementedException(); + public int CompareTo(IAccount other) => throw new NotImplementedException(); + public string Username { get; } + public string Email { get; set; } + public AccessLevel AccessLevel { get; set; } + public int Length { get; } + public int Limit { get; set; } = 6; // Default to 6 character slots + public int Count { get; } + + private readonly Dictionary _mobiles = new(); + public Mobile this[int index] + { + get => _mobiles.GetValueOrDefault(index); + set => _mobiles[index] = value; + } + + public DateTime Created { get; set; } + public Serial Serial { get; } + public void Deserialize(IGenericReader reader) => throw new NotImplementedException(); + public void Serialize(IGenericWriter writer) => throw new NotImplementedException(); + public bool Deleted { get; } + public void Delete() => throw new NotImplementedException(); + public bool TrySetUsername(string username) => throw new NotImplementedException(); + public void SetPassword(string password) => throw new NotImplementedException(); + public bool CheckPassword(string password) => throw new NotImplementedException(); +} diff --git a/Projects/Server.Tests/Helpers/PacketTestUtilities.cs b/Projects/Server.Tests/Helpers/PacketTestUtilities.cs index 5c6c793d4..48d344d39 100644 --- a/Projects/Server.Tests/Helpers/PacketTestUtilities.cs +++ b/Projects/Server.Tests/Helpers/PacketTestUtilities.cs @@ -20,7 +20,13 @@ public static class PacketTestUtilities /// Uses a real Socket and RingSocket with actual buffers. /// Must be disposed after use (use 'using' statement). ///
- public static NetState CreateTestNetState() + public static NetState CreateTestNetState() => CreateTestNetState(out _); + + /// + /// As , also handing back the peer socket so a test can read what the + /// server delivered. + /// + public static NetState CreateTestNetState(out Socket client) { NetState.Slice(); // Process disconnects/disposes @@ -65,6 +71,7 @@ public static class PacketTestUtilities var testSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); testSocket.Connect(IPAddress.Loopback, _testPort); _testSocketClients.Add(testSocket); + client = testSocket; // Slice until we have a new NetState instance added // AcceptEx is asynchronous, so we may need to wait/retry diff --git a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs index bfbc18e94..629ec3961 100644 --- a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs @@ -416,41 +416,6 @@ public class ClientEnumeratorTests } } - private class MockAccount : IAccount - { - public int TotalGold { get; } - public int TotalPlat { get; } - public bool DepositGold(int amount) => throw new NotImplementedException(); - public bool DepositPlat(int amount) => throw new NotImplementedException(); - public bool WithdrawGold(int amount) => throw new NotImplementedException(); - public bool WithdrawPlat(int amount) => throw new NotImplementedException(); - public long GetTotalGold() => throw new NotImplementedException(); - public int CompareTo(IAccount other) => throw new NotImplementedException(); - public string Username { get; } - public string Email { get; set; } - public AccessLevel AccessLevel { get; set; } - public int Length { get; } - public int Limit { get; set; } = 6; // Default to 6 character slots - public int Count { get; } - - private readonly Dictionary _mobiles = new(); - public Mobile this[int index] - { - get => _mobiles.GetValueOrDefault(index); - set => _mobiles[index] = value; - } - - public DateTime Created { get; set; } - public Serial Serial { get; } - public void Deserialize(IGenericReader reader) => throw new NotImplementedException(); - public void Serialize(IGenericWriter writer) => throw new NotImplementedException(); - public bool Deleted { get; } - public void Delete() => throw new NotImplementedException(); - public bool TrySetUsername(string username) => throw new NotImplementedException(); - public void SetPassword(string password) => throw new NotImplementedException(); - public bool CheckPassword(string password) => throw new NotImplementedException(); - } - private static (NetState, Mobile) CreateClientWithMobile(Map map, Point3D location) { // Create test NetState with real socket and buffers diff --git a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs new file mode 100644 index 000000000..adc513e2c --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs @@ -0,0 +1,272 @@ +using System; +using System.Diagnostics; +using System.Net.Sockets; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Send-side behaviour of a NetState after its disconnect is handed to the socket. +/// +[Collection("Sequential Server Tests")] +public class NetStateDisconnectTests +{ + private static NetState CreateAuthenticatedNetState() + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); // skips the unattached-socket sweep + return ns; + } + + private static (NetState ns, Mobile m) CreateClient(string name) + { + var ns = CreateAuthenticatedNetState(); + var m = new Mobile(World.NewMobile) { Name = name }; + m.DefaultMobileInit(); + ns.Mobile = m; + m.NetState = ns; + return (ns, m); + } + + [Fact] + public void Send_AfterDisconnectHandedToSocket_DropsPacket() + { + var ns = CreateAuthenticatedNetState(); + + try + { + ns.Disconnect("test"); + NetState.Slice(); // handoff; the in-flight recv keeps it pending + + Assert.True(ns.Running); + Assert.True(ns._socket.DisconnectPending); + Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes); + + ns.Send([0x73, 0x00]); + + Assert.True(ns.CannotSendPackets()); + Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes); + } + finally + { + ns.Dispose(); + } + } + + [Fact] + public void Send_AfterImmediateDisconnect_DropsPacket() + { + var ns = CreateAuthenticatedNetState(); + + // Immediate branch of RingSocket.Disconnect(): Connected drops, DisconnectPending never set, + // Disconnected event lands next Slice() + try + { + NetState.SocketManager.DisconnectImmediate(ns._socket); + + Assert.True(ns.Running); + Assert.False(ns._socket.Connected); + Assert.False(ns._socket.DisconnectPending); + + ns.Send([0x73, 0x00]); + + Assert.True(ns.CannotSendPackets()); + Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes); + } + finally + { + ns.Dispose(); + } + } + + [Fact] + public void Send_BeforeDisconnect_IsDeliveredThenPeerSeesEof() + { + var ns = PacketTestUtilities.CreateTestNetState(out var client); + ns.Account = new MockAccount(); + + try + { + byte[] payload = [0x8C, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + ns.Send(payload); + ns.Disconnect("redirect"); + NetState.Slice(); // flush, then handoff + + Assert.True(ns._socket.DisconnectPending); + + var received = new byte[payload.Length]; + var total = 0; + var deadline = Stopwatch.StartNew(); + + while (total < payload.Length && deadline.ElapsedMilliseconds < 5000) + { + NetState.Slice(); + if (client.Poll(1000, SelectMode.SelectRead)) + { + var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); + Assert.NotEqual(0, read); + total += read; + } + } + + Assert.Equal(payload, received); + + // FIN follows once the send completes + var eof = -1; + while (eof != 0 && deadline.ElapsedMilliseconds < 5000) + { + NetState.Slice(); + if (client.Poll(1000, SelectMode.SelectRead)) + { + eof = client.Receive(received); + } + } + + Assert.Equal(0, eof); + } + finally + { + ns.Dispose(); + client.Close(); + } + } + + [Fact] + public void Send_LargeBeforeDisconnect_IsFullyDrainedThenPeerSeesEof() + { + var ns = PacketTestUtilities.CreateTestNetState(out var client); + ns.Account = new MockAccount(); + + try + { + // Several posts' worth, larger than the loopback kernel buffers, so the drain spans completions + const int chunk = 32 * 1024; + var payload = new byte[6 * chunk]; + new System.Random(7).NextBytes(payload); + + for (var offset = 0; offset < payload.Length; offset += chunk) + { + ns.Send(payload.AsSpan(offset, chunk)); + } + + ns.Disconnect("redirect"); + NetState.Slice(); // flush, then handoff + + Assert.True(ns._socket.DisconnectPending); + + var received = new byte[payload.Length]; + var total = 0; + var deadline = Stopwatch.StartNew(); + + while (total < payload.Length && deadline.ElapsedMilliseconds < 10000) + { + NetState.Slice(); + if (client.Poll(1000, SelectMode.SelectRead)) + { + var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); + Assert.NotEqual(0, read); + total += read; + } + } + + Assert.Equal(payload.Length, total); + Assert.Equal(payload, received); + + var eof = -1; + while (eof != 0 && deadline.ElapsedMilliseconds < 10000) + { + NetState.Slice(); + if (client.Poll(1000, SelectMode.SelectRead)) + { + eof = client.Receive(received); + } + } + + Assert.Equal(0, eof); + } + finally + { + ns.Dispose(); + client.Close(); + } + } + + [Fact] + public void PendingDisconnect_ForceClosesAfterDrainTimeout() + { + var ns = CreateAuthenticatedNetState(); + + try + { + ns.Disconnect("test"); + NetState.Slice(); // handoff; the in-flight recv keeps it pending and arms the deadline + + Assert.True(ns._socket.DisconnectPending); + var armed = Core.TickCount; + + ns.CheckAlive(armed + NetState.DrainTimeoutMs - 1); + Assert.True(ns._socket.Connected); + + ns.CheckAlive(armed + NetState.DrainTimeoutMs); + Assert.False(ns._socket.Connected); + } + finally + { + ns.Dispose(); + } + } + + [Fact] + public void SendBufferExhausted_WhileDisconnectQueued_KeepsFirstReason() + { + var ns = CreateAuthenticatedNetState(); + + try + { + var capacity = ns._socket.SendBuffer.PhysicalSize; + + ns.Send(new byte[capacity + 1]); // cannot fit; queues the disconnect + ns.Send(new byte[capacity + 2]); // same tick; must not re-report + + Assert.Contains($"needed {capacity + 1}", ns._disconnectReason); + } + finally + { + ns.Dispose(); + } + } + + [Fact] + public void CancelAllTrades_CancelsEveryTrade() + { + var (from, fromMobile) = CreateClient("from"); + var (to, toMobile) = CreateClient("to"); + + try + { + from.AddTrade(to); + var trade = from.FindTrade(toMobile); + Assert.NotNull(trade); + Assert.True(trade.Valid); + + from.CancelAllTrades(); + + Assert.False(trade.Valid); + Assert.Null(from.Trades); + Assert.Null(to.Trades); + Assert.Null(from.FindTrade(toMobile)); + Assert.Null(to.FindTrade(fromMobile)); + } + finally + { + from.Mobile = null; + from.Dispose(); + fromMobile.Delete(); + to.Mobile = null; + to.Dispose(); + toMobile.Delete(); + } + } +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index f69f92076..5bedb504c 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -61,6 +61,9 @@ public partial class NetState /// public static IIORingGroup Ring => _socketManager?.Ring; + // Test hook + internal static RingSocketManager SocketManager => _socketManager; + /// /// Waits for network I/O completions or until the specified timeout expires. /// Used by the game loop to sleep efficiently while remaining responsive to network events. @@ -107,6 +110,9 @@ public partial class NetState return; } + // Seed from a real tick; a zero default suppresses the sweep when ticks start negative + _nextAliveCheck = Core.TickCount; + // Initialize IP rate limiter _ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token); @@ -539,6 +545,11 @@ public partial class NetState // - Waits for in-flight I/O to complete // - Ensures buffers aren't released while kernel is still using them ns._socket.Disconnect(); + + if (ns._socket.DisconnectPending) + { + ns.ArmDrainDeadline(curTicks); + } } } diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 04f1a5a76..e161462fc 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -36,6 +36,7 @@ public partial class NetState : IComparable, IValueLinkListNode _flushPending = new(2048); private static readonly Queue _pendingDisconnects = new(256); // Processed AFTER flush @@ -54,7 +55,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode= 0; --i) { - if (Trades != null) + // RemoveTrade() nulls the list once empty + if (Trades == null) { break; } @@ -489,6 +493,12 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode private void SendBufferExhausted(int needed, int writable) { + // One report per disconnect; the first reason wins + if (_disconnectQueued) + { + return; + } + var sendBuffer = _socket?.SendBuffer; var unacked = sendBuffer?.InFlightBytes ?? 0; var capacity = sendBuffer?.PhysicalSize ?? 0; @@ -1053,31 +1069,52 @@ public partial class NetState : IComparable, IValueLinkListNode= 0) + if (_socket == null) { return; } if (_socket.DisconnectPending) { - LogInfo("Force disconnecting stuck socket..."); - _socketManager.DisconnectImmediate(_socket); - } - else - { - // Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting. - // The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent. - if (_account != null && Mobile == null) + ArmDrainDeadline(curTicks); // transport-initiated drains are first seen here + + if (curTicks - _drainDeadline >= 0 || NextActivityCheck - curTicks < 0) { - this.SendClientVersionRequest(); - return; + LogInfo("Force disconnecting stuck socket..."); + _socketManager.DisconnectImmediate(_socket); } - LogInfo("Disconnecting due to inactivity..."); - Disconnect("Disconnecting due to inactivity."); + return; } + + if (NextActivityCheck - curTicks >= 0) + { + return; + } + + // Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting. + // The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent. + if (_account != null && Mobile == null) + { + this.SendClientVersionRequest(); + return; + } + + LogInfo("Disconnecting due to inactivity..."); + Disconnect("Disconnecting due to inactivity."); } public void Trace(ReadOnlySpan buffer) @@ -1123,8 +1160,8 @@ public partial class NetState : IComparable, IValueLinkListNode - /// Requests a graceful disconnect. The disconnect is queued and processed after the flush - /// queue in Slice(), ensuring Send() calls made in the same tick are processed first. + /// Requests a graceful disconnect. Processed after the flush queue in Slice(): sends made before + /// that handoff are flushed first, sends after it are dropped (see CannotSendPackets). /// public void Disconnect(string reason) { diff --git a/Projects/Server/Network/Packets/OutgoingPackets.cs b/Projects/Server/Network/Packets/OutgoingPackets.cs index ce2cce0f4..23b8f15c6 100644 --- a/Projects/Server/Network/Packets/OutgoingPackets.cs +++ b/Projects/Server/Network/Packets/OutgoingPackets.cs @@ -6,8 +6,8 @@ public static class OutgoingPackets { [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool CannotSendPackets(this NetState ns) => - // Do not check for NetState.Running. Packets are sent to a "disconnected" socket as part of the OnDisconnect events - // up until the socket is closed. Closing the connection is done synchronously, therefore packets will not be sent - // once the Mobile.NetState is null. - ns == null || ns.SocketHandle == 0 || ns.BlockAllPackets; + // Running is not checked: sends between Disconnect() and the Slice() handoff must still go out. + // After the handoff the socket is draining (DisconnectPending) or closing (!Connected); new writes + // only keep the buffer from draining. + ns == null || ns.SocketHandle == 0 || !ns._socket.Connected || ns._socket.DisconnectPending || ns.BlockAllPackets; } diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 8ec416218..030197955 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,7 +34,7 @@ - +