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)