## Problem Items dropped on the ground never decay. Corpses do, which makes the breakage look selective — but corpses are unaffected only because `Corpse.BeginDecay` runs its own `InternalTimer` and never touches `DecayScheduler`. Ordinary items are the only things that depend on the scheduler. ## Root cause `Item.MoveToWorld` called `SetLastMoved()` — which triggers `UpdateDecayRegistration()` — at the *top* of the method, before detaching the item from its parent and before assigning the new map. `CanDecay()` reads `Decays`, `Parent`, **and** `Map`, so registration was evaluated against the item's *pre-move* state. Because the `Item` constructor sets `m_Map = Map.Internal`, and `Mobile.Lift` calls `item.Internalize()` to put an item on the cursor, registration was consistently one step behind: | State | Tracked for decay? | | |---|---|---| | Item on the ground | **No** | never decays | | Item held on the cursor | **Yes** | backwards | Nothing corrected it afterwards: the later `m_Map = map` assigns the field directly, bypassing the `Map` property setter, and that setter does not refresh registration either. With no parent, `RemoveItem` (which *does* re-register) never runs. World load masked this — `ItemPersistence.PostDeserialize` re-registers every item against its final state, so decay appears to work for items that survive a restart. Only freshly dropped items are affected. **Fix:** stamp `LastMoved` up front so decay math stays correct, then call `UpdateDecayRegistration()` once the parent, map, and location are final. ## Audit of the rest of the call sites All 16 `SetLastMoved()` call sites were reviewed. `SetLastMoved()` must keep refreshing registration — `LastMoved` feeds `ScheduledDecayTime` and therefore which bucket an item belongs in — but it may only run once parent/map are final. The vendor, house, lift and drop sites already satisfy that. The rest of this PR fixes the ones that did not, plus what the audit turned up: - **`Item.Deserialize`** stamped via `SetLastMoved()` before the version data was read, registering against an unread `Map`/`Parent`. Safe only by accident (the `Item(Serial)` ctor leaves flags at 0, so `Decays` is false), and it cost an unregister per item per world load. Now stamps only; `PostDeserialize` does the registration. - **`Item` constructor** registered then immediately unregistered every item — the `Movable` setter saw `m_Map` still null, so `CanDecay()` was true. Also removes a `Configure`-order landmine: constructing an `Item` before `DecayScheduler.Configure()` would have thrown in `Shared.Start()`. - **`Container.Destroy`** stamped `LastMoved` immediately before `MoveToWorld`, which now stamps it itself. - **`Unregister` was documented O(1)** but scanned twelve buckets and did a linear `PriorityQueue.Remove`, on every construction, deserialize and move. Items now record where they are tracked in `Item.DecaySlot` (1 byte), so untracked items — the common case — leave in O(1). - **A refused decay silently dropped the item.** `ProcessActiveQueue` deleted on `OnDecay() == true` but did nothing when a region refused, leaving the item dequeued, untracked and on the ground forever. It now restarts the decay clock; re-registering as-is would spin, since `ScheduledDecayTime` is already past. ## Two content bugs of the same class The decay system replaced a polling sweep. Under polling, a `Decays`/`DecayTime` override could read live state every pass. Under a registration model it cannot — the scheduler drops items that stop being eligible, but nothing enrols one that becomes eligible while untracked. - **`TreasureChestLevel1-4`** overrode `DecayTime` as `Utility.Random(15, 60)` — a fresh roll on every read. `ScheduledDecayTime` is read repeatedly (to bucket, to re-bucket on rotation, to test whether due), so those reads disagreed: the chest re-bucketed every tick and decayed early instead of after its intended interval. Now rolled once per chest. Distribution unchanged — `Utility.Random(from, count)` is RunUO's `from + Next(count)`, so this is 15–74 minutes, as before. - **`StrongBox`** overrode `Decays` with a live check on `_house`, `_owner.Deleted` and `IsCoOwner`. Nothing notifies the box when any of those change, so it was never enrolled and the override never decayed anything — and it could not have: `HouseRegion.OnDecay` refuses a secured item inside a standing house, and the box is in `Secures`. Decay was never the mechanism here. - A strongbox is only ever its owner's. Without a house, or without an owner still co-owning that house, it would be a free container anyone could loot, so `Validate()` destroys it. The old check missed exactly those two cases — it required a non-null owner and treated a null house as valid. A deleted owner deserializes back as null, which `IsCoOwner` rejects. The now meaningless `Decays`/`DecayTime` overrides and an unhelpful `Console.WriteLine` are gone. `DecayScheduler` now documents both constraints. ## Tests `DecayRegistrationTests` (Server) covers world placement, lift/drop, container round-trip, cursor-held (must *not* track), `Container.Destroy` spill, `DecaySlot`/structure agreement, refused decay, and a full decay lifecycle driven through the scheduler. `TreasureChestDecayTests` (UOContent) locks `DecayTime`/`ScheduledDecayTime` stability across all four chest levels. To make the lifecycle testable deterministically, `DecayScheduler` gains `internal` members (visible only via existing `InternalsVisibleTo`): `IsRegistered()`, `ProcessTick(now)` — extracted from `OnTick()` with no behaviour change — and `ResetForTests()`. Red/green verified. Without the `MoveToWorld` fix, 5 of 6 of the original tests fail, including `ItemOnGround_ActuallyDecaysAfterDecayTime`, which shows a ground item never decays even after a full simulated hour. Without the chest fix, 8 of 8 chest tests fail. Without the refused-decay fix, that test fails. Server.Tests 737/737 and UOContent.Tests 509/509 pass.
147 lines
3.9 KiB
C#
147 lines
3.9 KiB
C#
using System;
|
|
using ModernUO.Serialization;
|
|
|
|
namespace Server.Items;
|
|
|
|
[SerializationGenerator(0, false)]
|
|
public partial class TreasureChestLevel3 : LockableContainer
|
|
{
|
|
[Constructible]
|
|
public TreasureChestLevel3() : base(0xE41)
|
|
{
|
|
SetChestAppearance();
|
|
Movable = false;
|
|
|
|
TrapType = TrapType.PoisonTrap;
|
|
TrapPower = 3 * Utility.Random(1, 25);
|
|
Locked = true;
|
|
|
|
RequiredSkill = 84;
|
|
LockLevel = RequiredSkill - Utility.Random(1, 10);
|
|
MaxLockLevel = RequiredSkill + Utility.Random(1, 10);
|
|
|
|
// According to OSI, loot in level 3 chest is:
|
|
// Gold 250 - 350
|
|
// Arrows 10
|
|
// Reagents
|
|
// Scrolls
|
|
// Potions
|
|
// Gems
|
|
// Magic Wand
|
|
// Magic weapon
|
|
// Magic armour
|
|
// Magic clothing (not implemented)
|
|
// Magic jewelry (not implemented)
|
|
|
|
// Gold
|
|
DropItem(new Gold(Utility.Random(180, 240)));
|
|
|
|
// Drop bolts
|
|
// DropItem( new Arrow( 10 ) );
|
|
|
|
// Reagents
|
|
for (var i = Utility.Random(2); i >= 0; i--)
|
|
{
|
|
var reagents = Loot.RandomReagent();
|
|
reagents.Amount = Utility.Random(1, 9);
|
|
DropItem(reagents);
|
|
}
|
|
|
|
// Scrolls
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
var scrolls = Loot.RandomScroll(0, 47, SpellbookType.Regular);
|
|
scrolls.Amount = Utility.Random(1, 12);
|
|
DropItem(scrolls);
|
|
}
|
|
|
|
// Potions
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
DropItem(Loot.RandomPotion());
|
|
}
|
|
|
|
// Gems
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
var gems = Loot.RandomGem();
|
|
gems.Amount = Utility.Random(1, 9);
|
|
DropItem(gems);
|
|
}
|
|
|
|
// Magic Wand
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
DropItem(Loot.RandomWand());
|
|
}
|
|
|
|
// Equipment
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
var item = Loot.RandomArmorOrShieldOrWeapon();
|
|
|
|
if (item is BaseWeapon weapon)
|
|
{
|
|
weapon.DamageLevel = (WeaponDamageLevel)Utility.Random(3);
|
|
weapon.AccuracyLevel = (WeaponAccuracyLevel)Utility.Random(3);
|
|
weapon.DurabilityLevel = (WeaponDurabilityLevel)Utility.Random(3);
|
|
weapon.Quality = WeaponQuality.Regular;
|
|
}
|
|
else if (item is BaseArmor armor)
|
|
{
|
|
armor.ProtectionLevel = (ArmorProtectionLevel)Utility.Random(3);
|
|
armor.Durability = (ArmorDurabilityLevel)Utility.Random(3);
|
|
armor.Quality = ArmorQuality.Regular;
|
|
}
|
|
|
|
DropItem(item);
|
|
}
|
|
|
|
// Clothing
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
DropItem(Loot.RandomClothing());
|
|
}
|
|
|
|
// Jewelry
|
|
for (var i = Utility.Random(3); i > 0; i--)
|
|
{
|
|
DropItem(Loot.RandomJewelry());
|
|
}
|
|
}
|
|
|
|
public override bool Decays => true;
|
|
|
|
public override bool IsDecoContainer => false;
|
|
|
|
// Rolled once: DecayScheduler reads ScheduledDecayTime repeatedly and needs a stable value.
|
|
private readonly TimeSpan _decayTime = TimeSpan.FromMinutes(Utility.Random(15, 60));
|
|
|
|
public override TimeSpan DecayTime => _decayTime;
|
|
|
|
public override int DefaultGumpID => 0x42;
|
|
|
|
public override int DefaultDropSound => 0x42;
|
|
|
|
public override Rectangle2D Bounds => new(18, 105, 144, 73);
|
|
|
|
private static readonly (int, int)[] _chestAppearances =
|
|
{
|
|
// Wooden Chest
|
|
(0xe42, 0x49),
|
|
(0xe43, 0x49),
|
|
|
|
// Metal Chest
|
|
(0x9ab, 0x4A),
|
|
(0xe7c, 0x4A),
|
|
|
|
// Metal Golden Chest
|
|
(0xe40, 0x42),
|
|
(0xe41, 0x42),
|
|
};
|
|
|
|
private void SetChestAppearance()
|
|
{
|
|
(ItemID, GumpID) = _chestAppearances.RandomElement();
|
|
}
|
|
}
|