diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs index 41444ea56..ff032f530 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -82,4 +82,112 @@ public class AnchoredItemSerializationTests restored?.Delete(); } } + + /// + /// LastMoved is the real time of the move, so a load after downtime must not shift it. + /// Shifting it rewrote every item record on the first save after every restart. + /// + [Fact] + public void LastMoved_IsNotShiftedByTheLoadAnchor() + { + var item = new Item(0x1F13); + var moved = Core.Now - TimeSpan.FromDays(30); + item.LastMoved = moved; + + var restored = new Item((Serial)0x7ffff124u); + restored.Deserialize(new BufferReader(SerializeItem(item)) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(default(DateTime), restored.DecayResetTime); + + item.Delete(); + restored.Delete(); + } + + /// + /// Downtime still does not count toward decay: a countdown running when the save started + /// resumes with the time it had left, carried by DecayResetTime. + /// + [Fact] + public void Downtime_IsCreditedToARunningDecayCountdown() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromMinutes(10); + var deadline = item.ScheduledDecayTime; + + var downtime = TimeSpan.FromHours(2); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(start - TimeSpan.FromMinutes(10), item.LastMoved); + Assert.Equal(deadline + downtime, item.ScheduledDecayTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// The common case: an item whose countdown had already run out gets no credit, so a restart + /// leaves its record byte-identical. + /// + [Fact] + public void ItemAlreadyDueAtSave_GetsNoCredit_AndKeepsItsBytes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromDays(3); + var before = SerializeItem(item); + + var downtime = TimeSpan.FromMinutes(30); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(default(DateTime), item.DecayResetTime); + Assert.Equal(before, SerializeItem(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Stat-gain stamps are absolute and not shifted at load either; downtime counts toward a + /// cooldown that is only minutes long. + /// + [Fact] + public void StatGainStamps_AreNotShiftedByTheLoadAnchor() + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.LastStrGain = Core.Now - TimeSpan.FromMinutes(3); + mobile.LastIntGain = Core.Now - TimeSpan.FromMinutes(2); + mobile.LastDexGain = Core.Now - TimeSpan.FromMinutes(1); + + var writer = new BufferWriter(new byte[4096], true); + mobile.Serialize(writer); + + var restored = new Mobile(World.NewMobile); + restored.Deserialize(new BufferReader(writer.Buffer[..(int)writer.Position]) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(mobile.LastStrGain, restored.LastStrGain); + Assert.Equal(mobile.LastIntGain, restored.LastIntGain); + Assert.Equal(mobile.LastDexGain, restored.LastDexGain); + + mobile.Delete(); + restored.Delete(); + } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 4226f100f..c67acdc85 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -1042,13 +1042,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - // Anchored: shifted by downtime at load, so time-since-moved is preserved and the - // bytes are stable across saves while the item does not move. - writer.WriteAnchoredTime(LastMoved); + // Absolute and never shifted at load, so an item that does not move serializes to the + // same bytes across saves and across restarts. Downtime credit for a decay countdown that + // was running at save time goes into DecayResetTime (CreditDowntimeToDecay). + writer.Write(LastMoved); if (GetSaveFlag(flags, SaveFlag.DecayReset)) { - writer.WriteAnchoredTime(info.m_DecayReset); + writer.Write(info.m_DecayReset); } if (GetSaveFlag(flags, SaveFlag.Direction)) @@ -2494,6 +2495,30 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert UpdateDecayRegistration(); } + /// + /// Credits the downtime before this load to a decay countdown that was still running when the + /// save started, so the shard being down does not age the item. stays + /// the real time of the move; the credit goes into , which only + /// the items moved within their of the save ever carry. Called once + /// per item after the world loads, when is final. An item already + /// due at save time gets nothing: it is due either way. + /// + internal void CreditDowntimeToDecay(DateTime saveStartUtc, TimeSpan downtime) + { + if (downtime <= TimeSpan.Zero || !Decays) + { + return; + } + + var reset = DecayResetTime; + var start = reset > LastMoved ? reset : LastMoved; + + if (start + DecayTime > saveStartUtc) + { + DecayResetTime = start + downtime; + } + } + public void UpdateDecayRegistration() { DecayScheduler.Unregister(this); @@ -2856,7 +2881,9 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 11) { - LastMoved = reader.ReadAnchoredTime(); + // Written with WriteAnchoredTime before LastMoved stopped being shifted; + // the bytes are the same absolute ticks either way. + LastMoved = reader.ReadDateTime(); } else if (version < 7) { @@ -2878,7 +2905,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var reset = version >= 11 ? reader.ReadDateTime() : reader.ReadDeltaTime(); // Pre-v11 LastMoved was stored at whole-minute precision; keep the // stamp only while it still extends the deadline. diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 52628e74a..e1cb74e9b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2343,9 +2343,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { writer.Write(38); // version - writer.WriteAnchoredTime(LastStrGain); - writer.WriteAnchoredTime(LastIntGain); - writer.WriteAnchoredTime(LastDexGain); + // Absolute and never shifted at load: the stat-gain cooldown is minutes long, so letting + // downtime count toward it costs nothing, and an idle mobile keeps its bytes across restarts. + writer.Write(LastStrGain); + writer.Write(LastIntGain); + writer.Write(LastDexGain); byte hairflag = 0x00; @@ -6179,9 +6181,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { if (version >= 38) { - LastStrGain = reader.ReadAnchoredTime(); - LastIntGain = reader.ReadAnchoredTime(); - LastDexGain = reader.ReadAnchoredTime(); + LastStrGain = reader.ReadDateTime(); + LastIntGain = reader.ReadDateTime(); + LastDexGain = reader.ReadDateTime(); } else { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 70e9c6aab..9fe12e4e9 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -529,6 +529,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer // The whole save shares one anchor. Publish it so payloads without their own // (GenericPersistence bins) can shift too; indexes load before any of them. World.LoadTimeShift = _anchoredTimeShift; + World.LoadedSaveStartTime = anchor; } if (version >= 4) diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 7ce1b52f2..7e968b925 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -108,6 +108,12 @@ public static class World /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. /// public static TimeSpan LoadTimeShift { get; internal set; } + + /// + /// UTC start time of the save being loaded, read from its index anchor. Default for saves + /// that predate the anchor. + /// + public static DateTime LoadedSaveStartTime { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -674,6 +680,7 @@ public static class World } item.ClearProperties(); + item.CreditDowntimeToDecay(LoadedSaveStartTime, LoadTimeShift); item.UpdateDecayRegistration(); } }