From 382956a9410ed06ace7f70f1b743bb11d4d44b55 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:49:34 -0700 Subject: [PATCH] fix: drop a superseded DecayResetTime so CompactInfo can collapse The reset stamp is only meaningful while it is ahead of LastMoved. Clearing it from the LastMoved setter covers every path that records a real move (SetLastMoved, MoveToWorld, direct writes) and lets VerifyCompactInfo free the CompactInfo instead of holding it forever for any item that was ever unfrozen. Deserialization applies the same guard, since minute rounding can land the stamp on LastMoved. The setter deliberately does not touch scheduler registration: MoveToWorld assigns LastMoved while parent/map are mid-transition and defers registration until its state is final. Co-Authored-By: Claude Fable 5 --- .../Tests/Items/DecayRegistrationTests.cs | 34 +++++++++++++++++++ Projects/Server/Items/Item.cs | 30 ++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) 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))