ModernUO/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs
Kamron Batman 971d7b6a77
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.
2026-08-22 12:56:00 -07:00

498 lines
15 KiB
C#

using System;
using Server.Items;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class DecayRegistrationTests
{
// The wheel is static and these tests move the clock, so start each from a known schedule.
public DecayRegistrationTests() => DecayScheduler.ResetForTests();
// Stands in for a region that refuses decay, e.g. HouseRegion over a locked down item.
private class RefusesDecayItem : Item
{
public RefusesDecayItem() : base(0x1234)
{
}
public override bool OnDecay() => false;
}
// The item is already dequeued, so a refusal that does not reschedule strands it forever.
[Fact]
public void ItemWhoseDecayIsRefused_StaysTrackedAndSurvives()
{
var start = Core._now;
try
{
var item = new RefusesDecayItem();
item.MoveToWorld(new Point3D(106, 100, 0), Map.Felucca);
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 so it is retried."
);
item.Delete();
}
finally
{
Core._now = start;
}
}
// Drives the scheduler and the game clock without depending on the shared timer wheel.
private static void AdvanceDecay(DateTime start, TimeSpan duration, Item item)
{
var deadline = start + duration;
for (var now = start; now <= deadline && !item.Deleted; now += TimeSpan.FromMilliseconds(256))
{
Core._now = now;
DecayScheduler.ProcessTick(now);
}
}
[Fact]
public void ItemOnGround_ActuallyDecaysAfterDecayTime()
{
var start = Core._now;
try
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca);
Assert.True(DecayScheduler.IsRegistered(item));
AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item);
Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses.");
}
finally
{
Core._now = start;
}
}
[Fact]
public void ItemOnGround_DoesNotDecayBeforeDecayTime()
{
var start = Core._now;
try
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca);
AdvanceDecay(start, item.DecayTime - TimeSpan.FromMinutes(2), item);
Assert.False(item.Deleted, "Item must not decay before DecayTime elapses.");
item.Delete();
}
finally
{
Core._now = start;
}
}
[Fact]
public void ItemPlacedInWorld_IsRegisteredForDecay()
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca);
Assert.True(item.CanDecay());
Assert.True(DecayScheduler.IsRegistered(item), "Item on the ground must be tracked for decay.");
item.Delete();
}
// The real client path: Mobile.Lift internalizes, then Mobile.Drop calls MoveToWorld.
[Fact]
public void ItemLiftedThenDroppedToGround_IsRegisteredForDecay()
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca);
// Player lifts the item to the cursor.
item.Internalize();
Assert.Equal(Map.Internal, item.Map);
// Player drops it back onto the ground.
item.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca);
Assert.True(item.CanDecay());
Assert.True(
DecayScheduler.IsRegistered(item),
"Item dropped to the ground after being lifted must be tracked for decay."
);
item.Delete();
}
// Held on the cursor means Map.Internal, which must not be tracked.
[Fact]
public void ItemLiftedToCursor_IsNotRegisteredForDecay()
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca);
item.Internalize();
Assert.False(item.CanDecay());
Assert.False(DecayScheduler.IsRegistered(item), "Item held on the cursor must not be tracked for decay.");
item.Delete();
}
// A new item is on Map.Internal, so construction must not touch the scheduler.
[Fact]
public void NewItem_IsNotTracked()
{
var item = new Item(0x1234);
Assert.Equal(DecayScheduler.SlotNone, item.DecaySlot);
Assert.False(DecayScheduler.IsRegistered(item), "A newly constructed item must not be tracked.");
item.Delete();
}
// Unregister() trusts DecaySlot; IsRegistered() scans the structures. Assert both to catch drift.
[Fact]
public void DecaySlot_AgreesWithStructures_AcrossLifecycle()
{
var item = new Item(0x1234);
item.MoveToWorld(new Point3D(103, 100, 0), Map.Felucca);
Assert.NotEqual(DecayScheduler.SlotNone, item.DecaySlot);
Assert.True(DecayScheduler.IsRegistered(item));
// Lifted to the cursor: untracked, and no stale slot left behind.
item.Internalize();
Assert.Equal(DecayScheduler.SlotNone, item.DecaySlot);
Assert.False(DecayScheduler.IsRegistered(item));
// Back to the ground: tracked again.
item.MoveToWorld(new Point3D(104, 100, 0), Map.Felucca);
Assert.NotEqual(DecayScheduler.SlotNone, item.DecaySlot);
Assert.True(DecayScheduler.IsRegistered(item));
item.Delete();
Assert.False(DecayScheduler.IsRegistered(item), "A deleted item must leave no trace in the scheduler.");
}
// Contents spilled onto the ground must start decaying.
[Fact]
public void ContainerDestroy_ContentsDroppedToGroundAreTracked()
{
var pack = new Container(0xE75);
pack.MoveToWorld(new Point3D(105, 100, 0), Map.Felucca);
var item = new Item(0x1234);
pack.AddItem(item);
Assert.False(DecayScheduler.IsRegistered(item));
pack.Destroy();
Assert.True(item.CanDecay());
Assert.True(DecayScheduler.IsRegistered(item), "Contents spilled by Container.Destroy must be tracked.");
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()
{
var pack = new Container(0xE75);
pack.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca);
var item = new Item(0x1234);
pack.AddItem(item);
Assert.False(DecayScheduler.IsRegistered(item), "Item inside a container must not be tracked for decay.");
item.Internalize();
item.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca);
Assert.True(item.CanDecay());
Assert.True(DecayScheduler.IsRegistered(item), "Item taken out of a container must be tracked for decay.");
item.Delete();
pack.Delete();
}
}