fix: stop items from insta-decaying when decay eligibility is restored without a move (#2583)
## Summary A GM flipping `Movable` back on for a long-frozen item made it vanish within one scheduler tick. The setter registered the item with a deadline computed from its stale `LastMoved`, so `ProcessActiveQueue` deleted it almost immediately. The pre-#2311 save-time sweep had the same semantics, just hidden behind the save cadence. The same failure existed for `Visible` and `Spawner` transitions. `LastMoved` is deliberately left meaning actual movement — it feeds vendor inventory expiry and house moving-crate checks — so the fix does not rewrite it for state changes. ## Changes - **`DecayResetTime`** (CompactInfo-backed): the decay countdown runs from the later of `LastMoved` and this stamp. `RestartDecay()` stamps it only when the item can decay and the stamp extends the current deadline, so hot paths with a fresh `LastMoved` allocate nothing. - **`Movable`/`Visible`/`Spawner` setters** call `RestartDecay()` instead of registering a stale deadline. - **Region-refusal retry** in `DecayScheduler` uses `RestartDecay()` instead of rewriting `LastMoved`. - **Persistence**: the stamp survives save/load as a `WriteDeltaTime` delta under `SaveFlag.DecayReset` (to become `WriteAnchoredTime` once the save-time anchor is ported) (Item serialization v10), so a restart mid-window no longer deletes the item. - **`LastMoved` setter** drops a superseded stamp so the `CompactInfo` can collapse instead of being held (~40 bytes) forever. - **Raw `Map` setter** now counts as a move for parentless items: it stamps `LastMoved` and updates decay registration, closing the gap where an item moved out of `Map.Internal` via the setter never decayed. - **`LiftItemDupe`**: the remainder of a partially lifted *ground* stack was placed via raw `Location`/`Map` assignments and never enrolled for decay (lingering-trash leak since #2311) — now enrolled via the Map setter. Parented remainders get their map from `AddItem` (parent first, then map), so container splits never transit the scheduler.
This commit is contained in:
parent
fd27b7a3c9
commit
971d7b6a77
4 changed files with 397 additions and 10 deletions
|
|
@ -208,6 +208,272 @@ public class DecayRegistrationTests
|
|||
item.Delete();
|
||||
}
|
||||
|
||||
// Unfreezing an item with a stale LastMoved must grant a fresh decay window,
|
||||
// not delete it on the next tick.
|
||||
[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.");
|
||||
|
||||
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 without rewriting LastMoved.
|
||||
[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));
|
||||
|
||||
// The stamp is stored as a delta, so it ages only by the real time between
|
||||
// write and read - milliseconds here, the downtime in production.
|
||||
Assert.True(
|
||||
(copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5),
|
||||
"The restarted decay window must survive a save/load cycle."
|
||||
);
|
||||
|
||||
item.Delete();
|
||||
copy.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse.
|
||||
[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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Losing decay eligibility makes the stamp meaningless; it must be dropped so the
|
||||
// CompactInfo is not held for as long as the item stays ineligible.
|
||||
[Fact]
|
||||
public void ItemBecomingIneligible_DropsTheDecayResetStamp()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
item.Movable = true;
|
||||
|
||||
Assert.NotEqual(default, item.DecayResetTime);
|
||||
|
||||
item.Movable = false;
|
||||
|
||||
Assert.Equal(default, item.DecayResetTime);
|
||||
|
||||
item.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// Moving a stamped item into a container programmatically (no drop, no SetLastMoved)
|
||||
// must also drop the stamp.
|
||||
[Fact]
|
||||
public void StampedItemAddedToContainer_DropsTheDecayResetStamp()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var pack = new Container(0xE75);
|
||||
pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca);
|
||||
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
item.Movable = true;
|
||||
|
||||
Assert.NotEqual(default, item.DecayResetTime);
|
||||
|
||||
pack.AddItem(item);
|
||||
|
||||
Assert.Equal(default, item.DecayResetTime);
|
||||
|
||||
pack.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.
|
||||
[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();
|
||||
}
|
||||
|
||||
// LiftItemDupe places the remainder of a partially lifted ground stack via raw
|
||||
// Location/Map assignments, with no MoveToWorld fallback: it must still be tracked.
|
||||
[Fact]
|
||||
public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay()
|
||||
{
|
||||
var stack = new Item(0x1234) { Stackable = true, Amount = 10 };
|
||||
stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca);
|
||||
|
||||
var remainder = Mobile.LiftItemDupe(stack, 3);
|
||||
|
||||
Assert.NotNull(remainder);
|
||||
Assert.Equal(7, remainder.Amount);
|
||||
Assert.Null(remainder.Parent);
|
||||
Assert.Equal(Map.Felucca, remainder.Map);
|
||||
Assert.True(
|
||||
DecayScheduler.IsRegistered(remainder),
|
||||
"The remainder of a partially lifted ground stack must be tracked for decay."
|
||||
);
|
||||
|
||||
stack.Delete();
|
||||
remainder.Delete();
|
||||
}
|
||||
|
||||
// Dropping into a container must untrack; taking it back out to the ground must re-track.
|
||||
[Fact]
|
||||
public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay()
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ public class DecayScheduler : Timer
|
|||
|
||||
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)
|
||||
{
|
||||
// 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
|
||||
// already left the queue; re-registering as-is would spin on a due time in the past.
|
||||
item.SetLastMoved();
|
||||
item.RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -335,7 +335,25 @@ 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 it so the CompactInfo can collapse.
|
||||
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
|
||||
|
|
@ -373,7 +391,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
}
|
||||
|
||||
Delta(ItemDelta.Update);
|
||||
UpdateDecayRegistration();
|
||||
RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -389,7 +407,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
SetFlag(ImplFlag.Movable, value);
|
||||
|
||||
Delta(ItemDelta.Update);
|
||||
UpdateDecayRegistration();
|
||||
RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -845,7 +863,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public virtual void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.Write(9); // version
|
||||
writer.Write(10); // version
|
||||
|
||||
var flags = SaveFlag.None;
|
||||
|
||||
|
|
@ -955,6 +973,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
{
|
||||
flags |= SaveFlag.SavedFlags;
|
||||
}
|
||||
|
||||
if (info.m_DecayReset > LastMoved)
|
||||
{
|
||||
flags |= SaveFlag.DecayReset;
|
||||
}
|
||||
}
|
||||
|
||||
if (info == null || info.m_Weight < 0)
|
||||
|
|
@ -1001,6 +1024,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
|
||||
/* end */
|
||||
|
||||
if (GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||
{
|
||||
//TODO Use WriteAnchoredTime once the save-time anchor is ported
|
||||
writer.WriteDeltaTime(info.m_DecayReset);
|
||||
}
|
||||
|
||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||
{
|
||||
writer.Write((byte)m_Direction);
|
||||
|
|
@ -1318,6 +1347,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
OnMapChange();
|
||||
|
||||
if (m_Parent == null)
|
||||
{
|
||||
// A map change is a move; nothing else updates decay registration for a raw Map change.
|
||||
SetLastMoved();
|
||||
}
|
||||
|
||||
if (old == null || old == Map.Internal)
|
||||
{
|
||||
InvalidateProperties();
|
||||
|
|
@ -1548,7 +1583,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
if (oldValue != value)
|
||||
{
|
||||
UpdateDecayRegistration();
|
||||
RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1742,6 +1777,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|| info.m_HeldBy != null
|
||||
|| info.m_BlessedFor != null
|
||||
|| info.m_Spawner != null
|
||||
|| info.m_DecayReset != default
|
||||
|| info.m_TempFlags != 0
|
||||
|| info.m_SavedFlags != 0
|
||||
|| info.m_Weight >= 0;
|
||||
|
|
@ -2326,7 +2362,64 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
public virtual bool OnDecay() =>
|
||||
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
|
||||
{
|
||||
if (value == default)
|
||||
{
|
||||
var info = LookupCompactInfo();
|
||||
|
||||
if (info != null && info.m_DecayReset != default)
|
||||
{
|
||||
info.m_DecayReset = default;
|
||||
VerifyCompactInfo();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AcquireCompactInfo().m_DecayReset = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the decay countdown without touching <see cref="LastMoved" />: call when decay
|
||||
/// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a
|
||||
/// stale <see cref="LastMoved" /> would otherwise decay the item on the 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()
|
||||
{
|
||||
|
|
@ -2336,6 +2429,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
{
|
||||
DecayScheduler.Register(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No countdown to anchor while ineligible; drop the stamp so the CompactInfo
|
||||
// can collapse. Re-eligibility always re-anchors.
|
||||
DecayResetTime = default;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLastMoved()
|
||||
|
|
@ -2673,6 +2772,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
switch (version)
|
||||
{
|
||||
case 10:
|
||||
case 9:
|
||||
case 8:
|
||||
case 7:
|
||||
|
|
@ -2698,6 +2798,18 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
}
|
||||
}
|
||||
|
||||
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||
{
|
||||
var reset = reader.ReadDeltaTime();
|
||||
|
||||
// LastMoved is stored at whole-minute precision; keep the stamp only
|
||||
// while it still extends the deadline.
|
||||
if (reset > LastMoved)
|
||||
{
|
||||
DecayResetTime = reset;
|
||||
}
|
||||
}
|
||||
|
||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||
{
|
||||
m_Direction = (Direction)reader.ReadByte();
|
||||
|
|
@ -4361,6 +4473,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public ISpawner m_Spawner;
|
||||
|
||||
public DateTime m_DecayReset;
|
||||
|
||||
public int m_TempFlags;
|
||||
|
||||
public double m_Weight = -1;
|
||||
|
|
@ -4399,6 +4513,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
IntWeight = 0x01000000,
|
||||
SavedFlags = 0x02000000,
|
||||
NullWeight = 0x04000000,
|
||||
PlayerConstructed = 0x08000000
|
||||
PlayerConstructed = 0x08000000,
|
||||
DecayReset = 0x10000000
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5250,7 +5250,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
item.PlayerConstructed = oldItem.PlayerConstructed;
|
||||
item.Amount = oldAmount - amount;
|
||||
|
||||
// A parented remainder gets its map from AddItem (parent first, then map), keeping the
|
||||
// split off the decay scheduler; a ground remainder is placed and enrolled here.
|
||||
if (oldItem.Parent == null)
|
||||
{
|
||||
item.Map = oldItem.Map;
|
||||
}
|
||||
|
||||
oldItem.OnAfterDuped(item);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue