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 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-22 10:49:34 -07:00
parent 107ca3c1c0
commit 382956a941
2 changed files with 62 additions and 2 deletions

View file

@ -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]

View file

@ -335,7 +335,26 @@ public partial class Item : IHued, IComparable<Item>, 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<Item>, 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))