ModernUO/Projects/Server/Items/DecayScheduler.cs
Kamron Batman 107ca3c1c0 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>
2026-08-22 10:42:24 -07:00

436 lines
14 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: DecayScheduler.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
namespace Server.Items;
/// <summary>
/// Timer wheel-based decay scheduler with O(1) registration/unregistration.
/// Uses coarse-grained HashSet buckets for time intervals, with a PriorityQueue
/// for fine-grained processing of items due within the current window.
/// <para>
/// An item that becomes decay eligible must be passed to <see cref="Item.UpdateDecayRegistration" />
/// once its parent, map and location are final. The scheduler drops items that stop being eligible,
/// but nothing enrolls one that becomes eligible while untracked.
/// </para>
/// <para>
/// <see cref="Item.Decays" /> and <see cref="Item.DecayTime" /> overrides must be stable rather than
/// recomputed per read: <see cref="Item.ScheduledDecayTime" /> is read repeatedly, and a value that
/// changes between reads re-buckets the item every tick and decays it at the wrong time.
/// </para>
/// </summary>
public class DecayScheduler : Timer
{
// Item.DecaySlot values; any non-negative value is a bucket index.
internal const sbyte SlotNone = -1;
internal const sbyte SlotOverflow = -2;
internal const sbyte SlotActive = -3;
private const int BucketCount = 12;
private static int _maxItemsPerTick;
private static TimeSpan _tickInterval;
private static TimeSpan _bucketInterval;
private static int _jitterMaxMilliseconds;
// Timer wheel buckets (HashSets for O(1) add/remove)
private static HashSet<Item>[] _buckets;
private static int _currentBucketIndex;
private static DateTime _nextBucketRotation;
// Overflow bucket (separate from regular rotation, for items with decay > total bucket span)
private static HashSet<Item> _overflowBucket;
private static TimeSpan _totalBucketSpan;
private static DateTime _nextOverflowCheck;
// Active processing queue for items due within current bucket window
private static readonly PriorityQueue<Item, DateTime> _activeQueue = new();
public static DecayScheduler Shared { get; private set; }
public static void Configure()
{
_maxItemsPerTick = ServerConfiguration.GetSetting("decay.maxItemsPerTick", 250);
_tickInterval = ServerConfiguration.GetSetting("decay.tickInterval", TimeSpan.FromMilliseconds(256));
_bucketInterval = ServerConfiguration.GetSetting("decay.bucketInterval", TimeSpan.FromMinutes(5));
_jitterMaxMilliseconds = ServerConfiguration.GetSetting("decay.jitterMaxMs", 25); // ±25ms jitter
_buckets = new HashSet<Item>[BucketCount];
for (var i = 0; i < BucketCount; i++)
{
_buckets[i] = [];
}
// Overflow bucket is separate from the timer wheel
_overflowBucket = [];
_totalBucketSpan = TimeSpan.FromTicks(_bucketInterval.Ticks * BucketCount);
var now = Core.Now;
_nextBucketRotation = now + _bucketInterval;
_nextOverflowCheck = now + _totalBucketSpan;
Shared = new DecayScheduler();
}
private DecayScheduler() : base(_tickInterval, _tickInterval)
{
}
/// <summary>
/// Test hook: drops all tracked items and re-bases the rotation schedule on
/// <see cref="Core.Now" />, so deadlines left by a test that moved the clock do not suppress
/// rotations in the next one.
/// </summary>
internal static void ResetForTests()
{
Shared?.Stop();
_activeQueue.Clear();
_overflowBucket.Clear();
for (var i = 0; i < BucketCount; i++)
{
_buckets[i].Clear();
}
_currentBucketIndex = 0;
var now = Core.Now;
_nextBucketRotation = now + _bucketInterval;
_nextOverflowCheck = now + _totalBucketSpan;
}
/// <summary>
/// Diagnostics: true if the item is currently tracked in any bucket, the overflow
/// bucket, or the active queue.
/// </summary>
internal static bool IsRegistered(Item item)
{
if (item == null)
{
return false;
}
if (_overflowBucket.Contains(item))
{
return true;
}
for (var i = 0; i < BucketCount; i++)
{
if (_buckets[i].Contains(item))
{
return true;
}
}
foreach (var (queued, _) in _activeQueue.UnorderedItems)
{
if (queued == item)
{
return true;
}
}
return false;
}
/// <summary>
/// Checks if all decay tracking structures are empty.
/// </summary>
private static bool IsEmpty()
{
if (_activeQueue.Count > 0 || _overflowBucket.Count > 0)
{
return false;
}
for (var i = 0; i < BucketCount; i++)
{
if (_buckets[i].Count > 0)
{
return false;
}
}
return true;
}
/// <summary>
/// Registers an item for decay tracking. Call after item becomes decay-eligible.
/// </summary>
public static void Register(Item item)
{
if (item?.Deleted != false || !item.CanDecay())
{
return;
}
// Start timer if not running
Shared.Start();
var decayTime = item.ScheduledDecayTime;
var timeUntilDecay = decayTime - Core.Now;
// If already due or due very soon, add directly to active queue
if (timeUntilDecay <= _bucketInterval)
{
_activeQueue.Enqueue(item, decayTime);
item.DecaySlot = SlotActive;
}
// If beyond the total bucket span, add to overflow bucket
else if (timeUntilDecay > _totalBucketSpan)
{
_overflowBucket.Add(item);
item.DecaySlot = SlotOverflow;
}
else
{
// Calculate bucket index relative to current position
var bucketOffset = GetBucketOffset(timeUntilDecay);
var absoluteIndex = (_currentBucketIndex + bucketOffset) % BucketCount;
_buckets[absoluteIndex].Add(item);
item.DecaySlot = (sbyte)absoluteIndex;
}
}
/// <summary>
/// Unregisters an item from decay tracking. Call when item is picked up, deleted, or otherwise ineligible.
/// </summary>
public static void Unregister(Item item)
{
if (item == null || item.DecaySlot == SlotNone)
{
return;
}
var slot = item.DecaySlot;
item.DecaySlot = SlotNone;
if (slot == SlotOverflow)
{
_overflowBucket.Remove(item);
}
else if (slot == SlotActive)
{
_activeQueue.Remove(item, out _, out _);
}
else
{
_buckets[slot].Remove(item);
}
}
/// <summary>
/// Gets the bucket offset for an item based on time until decay.
/// Returns a value from 0 to BucketCount - 1, representing how many buckets ahead of current.
/// </summary>
private static int GetBucketOffset(TimeSpan timeUntilDecay)
{
// Items due within _bucketInterval go to active queue, so offset 0 means _bucketInterval to 2*_bucketInterval
var bucketOffset = (int)(timeUntilDecay.Ticks / _bucketInterval.Ticks) - 1;
// Clamp to valid range within regular buckets
return Math.Clamp(bucketOffset, 0, BucketCount - 1);
}
/// <summary>
/// Advances the wheel for <paramref name="now" />. Returns true when nothing is left to track.
/// </summary>
internal static bool ProcessTick(DateTime now)
{
// Process items from active queue first (smaller queue = faster log(n) operations)
ProcessActiveQueue(now);
// Then check if it's time to rotate buckets (adds items for next tick)
if (now >= _nextBucketRotation)
{
RotateBuckets(now);
}
// Check overflow bucket on its own schedule (when items might enter the regular window)
if (now >= _nextOverflowCheck)
{
ProcessOverflow(now);
}
return IsEmpty();
}
protected override void OnTick()
{
// Stop timer if nothing left to track
if (ProcessTick(Core.Now))
{
Stop();
return;
}
// Apply jitter for next tick to prevent synchronization with other systems
if (_jitterMaxMilliseconds > 0)
{
var jitter = Utility.Random(-_jitterMaxMilliseconds, _jitterMaxMilliseconds * 2 + 1);
Interval = _tickInterval + TimeSpan.FromMilliseconds(jitter);
}
}
/// <summary>
/// Rotates the timer wheel, moving the next bucket's contents into the active queue.
/// </summary>
private static void RotateBuckets(DateTime now)
{
_nextBucketRotation = now + _bucketInterval;
// Get the next bucket to process
var bucket = _buckets[_currentBucketIndex];
// Move all items from this bucket into the active queue (with validation)
foreach (var item in bucket)
{
if (item.Deleted || !item.CanDecay())
{
item.DecaySlot = SlotNone; // Lazy cleanup - item was picked up or deleted
continue;
}
var decayTime = item.ScheduledDecayTime;
var timeUntilDecay = decayTime - now;
if (timeUntilDecay > _bucketInterval)
{
// Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow
if (timeUntilDecay > _totalBucketSpan)
{
// Extended beyond total span - move to overflow
_overflowBucket.Add(item);
item.DecaySlot = SlotOverflow;
}
else
{
// Re-bucket within regular buckets
var bucketOffset = GetBucketOffset(timeUntilDecay);
var actualIndex = (_currentBucketIndex + bucketOffset) % BucketCount;
if (actualIndex != _currentBucketIndex)
{
_buckets[actualIndex].Add(item);
item.DecaySlot = (sbyte)actualIndex;
}
else
{
// Still maps to current bucket - add to active queue
_activeQueue.Enqueue(item, decayTime);
item.DecaySlot = SlotActive;
}
}
continue;
}
// Due within next window - add to active queue
_activeQueue.Enqueue(item, decayTime);
item.DecaySlot = SlotActive;
}
// Clear the processed bucket
bucket.Clear();
// Advance to next bucket
_currentBucketIndex = (_currentBucketIndex + 1) % BucketCount;
}
/// <summary>
/// Processes the overflow bucket, moving items that are now within the regular bucket window.
/// </summary>
private static void ProcessOverflow(DateTime now)
{
_nextOverflowCheck = now + _totalBucketSpan;
// Move items that are now within the regular bucket span
_overflowBucket.RemoveWhere(item =>
{
if (item.Deleted || !item.CanDecay())
{
item.DecaySlot = SlotNone;
return true; // Remove invalid items
}
var decayTime = item.ScheduledDecayTime;
var timeUntilDecay = decayTime - now;
if (timeUntilDecay > _totalBucketSpan)
{
return false; // Keep in overflow
}
// Now within regular bucket range - move to appropriate bucket
if (timeUntilDecay <= _bucketInterval)
{
_activeQueue.Enqueue(item, decayTime);
item.DecaySlot = SlotActive;
}
else
{
var bucketOffset = GetBucketOffset(timeUntilDecay);
var actualIndex = (_currentBucketIndex + bucketOffset) % BucketCount;
_buckets[actualIndex].Add(item);
item.DecaySlot = (sbyte)actualIndex;
}
return true; // Remove from overflow
});
}
/// <summary>
/// Processes items from the active queue that are due for decay.
/// </summary>
private static void ProcessActiveQueue(DateTime now)
{
var processed = 0;
while (_activeQueue.TryPeek(out var item, out var scheduledTime) && processed < _maxItemsPerTick)
{
// Not yet due - stop processing
if (scheduledTime > now)
{
break;
}
processed++;
_activeQueue.Dequeue();
item.DecaySlot = SlotNone;
if (item.Deleted || !item.CanDecay())
{
continue;
}
if (item.ScheduledDecayTime > now)
{
Register(item);
}
else if (item.OnDecay())
{
item.Delete();
}
else
{
// 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.RestartDecay();
}
}
}
}