diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 2ceeebf7f..63204165f 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -340,6 +340,40 @@ public class DecayRegistrationTests } } + // Once a real move supersedes the reset stamp, the stamp must be dropped so the item's + // CompactInfo can collapse instead of holding ~40 bytes forever. + [Fact] + public void MovingAnItem_ClearsASupersededDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + // A real move supersedes the stamp. + Core._now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must enroll // an untracked item for decay instead of leaving it to linger forever. [Fact] diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 270fecb01..7cddaaa21 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -335,7 +335,26 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - public DateTime LastMoved { get; set; } + private DateTime _lastMoved; + + public DateTime LastMoved + { + get => _lastMoved; + set + { + _lastMoved = value; + + // A move at or past the reset stamp supersedes it; drop the stamp so the + // CompactInfo it lives in can collapse instead of being held forever. + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -2761,7 +2780,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - DecayResetTime = Core.Now - TimeSpan.FromMinutes(reader.ReadEncodedInt()); + var reset = Core.Now - TimeSpan.FromMinutes(reader.ReadEncodedInt()); + + // Minute rounding can land the stamp on LastMoved; only keep it while + // it still extends the deadline, so CompactInfo is not held for nothing. + if (reset > LastMoved) + { + DecayResetTime = reset; + } } if (GetSaveFlag(flags, SaveFlag.Direction))