fix: stop items from insta-decaying when eligibility is restored without a move

A GM flipping Movable (or Visible) back on for a long-frozen item registered
it with a deadline computed from its stale LastMoved, so the scheduler
deleted it on the next tick. The old save-time decay sweep had the same
semantics but hid them behind the save cadence.

Introduce DecayResetTime (CompactInfo-backed, persisted as delta minutes
under a new SaveFlag with an Item version bump): the decay countdown now
runs from the later of LastMoved and DecayResetTime. RestartDecay() stamps
it only when the item can decay and the stamp extends the deadline, so hot
paths with a fresh LastMoved allocate nothing.

- Movable/Visible/Spawner setters restart the countdown instead of
  registering a stale deadline
- The region-refusal retry in DecayScheduler restarts the countdown without
  rewriting LastMoved, which is reserved for actual moves
- A raw Map assignment (e.g. props) now counts as a move: it stamps
  LastMoved and enrolls/withdraws the item, closing the gap where an item
  moved out of Map.Internal via the setter never decayed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-22 10:42:24 -07:00
parent fd27b7a3c9
commit 107ca3c1c0
3 changed files with 225 additions and 8 deletions

View file

@ -208,6 +208,154 @@ public class DecayRegistrationTests
item.Delete(); item.Delete();
} }
// A GM freezes an item, time passes, then unfreezes it. The stale LastMoved must not
// let the scheduler delete it on the next tick; it gets a fresh decay window instead.
[Fact]
public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow()
{
var start = Core._now;
try
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca);
item.Movable = false;
Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay.");
// Months pass while the item sits frozen; LastMoved goes stale.
Core._now = start + TimeSpan.FromDays(30);
var flipped = Core._now;
item.Movable = true;
Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay.");
AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item);
Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately.");
AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item);
Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses.");
}
finally
{
Core._now = start;
}
}
// Same transition through the Visible setter: unhiding a long-hidden item.
[Fact]
public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow()
{
var start = Core._now;
try
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca);
item.Visible = false;
Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay.");
Core._now = start + TimeSpan.FromDays(30);
var flipped = Core._now;
item.Visible = true;
Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay.");
AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item);
Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately.");
AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item);
Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses.");
}
finally
{
Core._now = start;
}
}
// A refusal restarts the countdown; it must not pretend the item moved.
[Fact]
public void RefusedDecay_DoesNotRewriteLastMoved()
{
var start = Core._now;
try
{
var item = new RefusesDecayItem();
item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca);
var lastMoved = item.LastMoved;
AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item);
Assert.False(item.Deleted, "A refused decay must not delete the item.");
Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked.");
Assert.Equal(lastMoved, item.LastMoved);
item.Delete();
}
finally
{
Core._now = start;
}
}
// The fresh window must survive a save/load cycle, or a restart mid-window deletes the item.
[Fact]
public void FreshDecayWindow_SurvivesSerialization()
{
var start = Core._now;
try
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca);
item.Movable = false;
Core._now = start + TimeSpan.FromDays(30);
item.Movable = true;
var expected = item.ScheduledDecayTime;
var writer = new BufferWriter(new byte[512], true);
item.Serialize(writer);
var copy = new Item(item.Serial);
copy.Deserialize(new BufferReader(writer.Buffer));
// LastMoved persists as whole minutes; allow that much slack.
Assert.True(
(copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromMinutes(1),
"The restarted decay window must survive a save/load cycle."
);
item.Delete();
copy.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]
public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay()
{
var item = new Item(0x1234);
Assert.False(DecayScheduler.IsRegistered(item));
item.Map = Map.Felucca;
Assert.True(item.CanDecay());
Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked.");
item.Delete();
}
// Dropping into a container must untrack; taking it back out to the ground must re-track. // Dropping into a container must untrack; taking it back out to the ground must re-track.
[Fact] [Fact]
public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay()

View file

@ -311,7 +311,7 @@ public class DecayScheduler : Timer
if (timeUntilDecay > _bucketInterval) if (timeUntilDecay > _bucketInterval)
{ {
// Item was moved (SetLastMoved called) - re-bucket or move to overflow // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow
if (timeUntilDecay > _totalBucketSpan) if (timeUntilDecay > _totalBucketSpan)
{ {
// Extended beyond total span - move to overflow // Extended beyond total span - move to overflow
@ -429,7 +429,7 @@ public class DecayScheduler : Timer
{ {
// Refused by the region. Restart the clock rather than dropping the item, which has // Refused by the region. Restart the clock rather than dropping the item, which has
// already left the queue; re-registering as-is would spin on a due time in the past. // already left the queue; re-registering as-is would spin on a due time in the past.
item.SetLastMoved(); item.RestartDecay();
} }
} }
} }

View file

@ -373,7 +373,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
Delta(ItemDelta.Update); Delta(ItemDelta.Update);
UpdateDecayRegistration(); RestartDecay();
} }
} }
} }
@ -389,7 +389,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
SetFlag(ImplFlag.Movable, value); SetFlag(ImplFlag.Movable, value);
Delta(ItemDelta.Update); Delta(ItemDelta.Update);
UpdateDecayRegistration(); RestartDecay();
} }
} }
} }
@ -845,7 +845,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual void Serialize(IGenericWriter writer) public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(9); // version writer.Write(10); // version
var flags = SaveFlag.None; var flags = SaveFlag.None;
@ -955,6 +955,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{ {
flags |= SaveFlag.SavedFlags; flags |= SaveFlag.SavedFlags;
} }
if (info.m_DecayReset > LastMoved)
{
flags |= SaveFlag.DecayReset;
}
} }
if (info == null || info.m_Weight < 0) if (info == null || info.m_Weight < 0)
@ -1001,6 +1006,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
/* end */ /* end */
if (GetSaveFlag(flags, SaveFlag.DecayReset))
{
var resetMinutes = new TimeSpan(now - info.m_DecayReset.Ticks).TotalMinutes;
writer.WriteEncodedInt((int)Math.Clamp(resetMinutes, 0, int.MaxValue));
}
if (GetSaveFlag(flags, SaveFlag.Direction)) if (GetSaveFlag(flags, SaveFlag.Direction))
{ {
writer.Write((byte)m_Direction); writer.Write((byte)m_Direction);
@ -1318,6 +1329,13 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
OnMapChange(); OnMapChange();
if (m_Parent == null)
{
// A map change is a move; this also enrolls/withdraws the item for decay,
// since nothing else tracks eligibility gained through a raw Map change.
SetLastMoved();
}
if (old == null || old == Map.Internal) if (old == null || old == Map.Internal)
{ {
InvalidateProperties(); InvalidateProperties();
@ -1548,7 +1566,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
if (oldValue != value) if (oldValue != value)
{ {
UpdateDecayRegistration(); RestartDecay();
} }
} }
} }
@ -1742,6 +1760,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|| info.m_HeldBy != null || info.m_HeldBy != null
|| info.m_BlessedFor != null || info.m_BlessedFor != null
|| info.m_Spawner != null || info.m_Spawner != null
|| info.m_DecayReset != default
|| info.m_TempFlags != 0 || info.m_TempFlags != 0
|| info.m_SavedFlags != 0 || info.m_SavedFlags != 0
|| info.m_Weight >= 0; || info.m_Weight >= 0;
@ -2326,7 +2345,48 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual bool OnDecay() => public virtual bool OnDecay() =>
CanDecay() && Region.Find(Location, Map).OnDecay(this); CanDecay() && Region.Find(Location, Map).OnDecay(this);
public DateTime ScheduledDecayTime => LastMoved + DecayTime; public DateTime ScheduledDecayTime
{
get
{
var reset = DecayResetTime;
var lastMoved = LastMoved;
return (reset > lastMoved ? reset : lastMoved) + DecayTime;
}
}
/// <summary>
/// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it.
/// The decay countdown runs from the later of this and <see cref="LastMoved" />.
/// </summary>
public DateTime DecayResetTime
{
get => LookupCompactInfo()?.m_DecayReset ?? default;
private set => AcquireCompactInfo().m_DecayReset = value;
}
/// <summary>
/// Restarts the decay countdown without pretending the item moved: call when decay eligibility
/// changes state (Movable/Visible/Spawner) or a region refuses a decay. Otherwise a stale
/// <see cref="LastMoved" /> makes the scheduler delete the item on its next tick.
/// Stamps <see cref="DecayResetTime" /> only when that extends the current deadline, then
/// updates the scheduler registration.
/// </summary>
public void RestartDecay()
{
if (CanDecay())
{
var now = Core.Now;
if (ScheduledDecayTime < now + DecayTime)
{
DecayResetTime = now;
}
}
UpdateDecayRegistration();
}
public void UpdateDecayRegistration() public void UpdateDecayRegistration()
{ {
@ -2673,6 +2733,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
switch (version) switch (version)
{ {
case 10:
case 9: case 9:
case 8: case 8:
case 7: case 7:
@ -2698,6 +2759,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
} }
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
{
DecayResetTime = Core.Now - TimeSpan.FromMinutes(reader.ReadEncodedInt());
}
if (GetSaveFlag(flags, SaveFlag.Direction)) if (GetSaveFlag(flags, SaveFlag.Direction))
{ {
m_Direction = (Direction)reader.ReadByte(); m_Direction = (Direction)reader.ReadByte();
@ -4361,6 +4427,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public ISpawner m_Spawner; public ISpawner m_Spawner;
public DateTime m_DecayReset;
public int m_TempFlags; public int m_TempFlags;
public double m_Weight = -1; public double m_Weight = -1;
@ -4399,6 +4467,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
IntWeight = 0x01000000, IntWeight = 0x01000000,
SavedFlags = 0x02000000, SavedFlags = 0x02000000,
NullWeight = 0x04000000, NullWeight = 0x04000000,
PlayerConstructed = 0x08000000 PlayerConstructed = 0x08000000,
DecayReset = 0x10000000
} }
} }