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>
380 lines
12 KiB
C#
380 lines
12 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();
|
|
}
|
|
|
|
// 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.
|
|
[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();
|
|
}
|
|
}
|