fix: Removes side effects from locked down items, decaying houses, bracelet of binding, and builds (#2608)
## Why Delta world saves (only changed entities re-serialized during the freeze) need two things from content: `Serialize` must be pure, and an unchanged entity must produce identical bytes on every save. An audit of the tree found the places that break this today. This PR fixes them and adds a diagnostic that measures byte stability in a running world over real time. No wire format changes, no version bumps. ## What - **Guild**: `Serialize` ran the daily guildmaster recalculation (dictionary-order tie breaks, random picks, mutates another entity), war expiry, and the alliance leadership check on every save. That work now runs from `GuildMaintenanceTimer` (the former `WarTimer`, now started for both guild systems; the war and alliance checks are no-ops without the new guild system). - **BaseHouse**: the `DecayLevel` getter wrote `LastRefreshed` and advanced the dynamic decay stage on every read, so any undecayable house changed its persisted bytes whenever a sign, gump, or `[Props` looked at it. It is now a pure read; `UpdateDecay()` on the decay tick advances the stage and refreshes a house that leaves the undecayable state. - **Locked-down container contents decay through `DecayScheduler`** instead of a sweep inside `BaseHouse.Serialize`. `Item.CanDecay` now accepts a parent container whose new `Container.ContentsDecay` is true (locked down and not secure) when the content item is not itself locked down or secured; the `IsLockedDown`/`IsSecure` setters re-evaluate a container and its direct contents; `OnDecay` resolves the region from the world location. `BaseBoard`, `Aquarium` and `FishBowl` override `ContentsDecay` to false, matching the exclusions of the old sweep. Items already inside such containers are picked up at load, where every item re-evaluates its registration. The per-save O(all locked-down items) walk is gone; the work is now event driven. - **BraceletOfBinding**: the `Bound` getter nulled its backing field for a deleted target; it now returns null for a missing or deleted target without writing. - **`[SaveStability [delaySeconds=60] [sampleStride=1]`** (Administrator): hashes the serialized bytes of every entity in every registered entity persistence (`Persistence.EntityPersistences`, new), waits the delay in real time, hashes again, and reports per type how many records changed. Both passes are chunked under a 20 ms per-tick budget. Serializing twice inside one tick cannot see drift derived from `Core.Now`, which is frozen within a tick; the real-time wait is the point. On an idle shard the only legitimate churn is NPC movement and regeneration, so a static type with a high changed fraction is a serialization bug. `[SaveStability cancel` stops a run. Engine additions: `Container.ContentsDecay` / `UpdateContentsDecayRegistration()`, the `CanDecay`/`OnDecay` change above, and diagnostic-facing `IGenericEntityPersistence.Name`, `EntityCount`, `EnumerateEntities()`, and `Persistence.EntityPersistences`. ## Behaviour notes - Fealty recalculation now happens on the minute timer for both guild systems instead of at save time. - A house that becomes decayable is refreshed on its next decay tick rather than at the exact moment of transition (at most one minute later). - Contents of locked-down containers now decay on their own schedule (same `LastMoved + DecayTime` rule) instead of at the next world save after becoming due. ## Testing - New tests: `GuildSerializePurityTests`, `HouseDecayPurityTests`, `SaveStabilityTests` (stable entities hash identically after the clock advances; a clock-stamped record is reported by type only after the clock advances; the snapshot covers Items, Mobiles and Guilds). - `LockedDownContainerDecayTests` (Server.Tests): registration on drop, lock/secure/release transitions, excluded containers, nested containers, and decay on schedule through the scheduler. - Full suites: Server.Tests 855 passed, UOContent.Tests 765 passed.
This commit is contained in:
parent
e52d54b7da
commit
a2c232f4a8
15 changed files with 921 additions and 67 deletions
|
|
@ -0,0 +1,173 @@
|
|||
using System;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class LockedDownContainerDecayTests
|
||||
{
|
||||
public LockedDownContainerDecayTests()
|
||||
{
|
||||
DecayScheduler.ResetForTests();
|
||||
|
||||
// Content assigns these at startup (BaseHouse); the engine tests have no content.
|
||||
if (Item.LockedDownFlag == 0)
|
||||
{
|
||||
Item.LockedDownFlag = 1;
|
||||
Item.SecureFlag = 2;
|
||||
}
|
||||
}
|
||||
|
||||
private class TestChest : Container
|
||||
{
|
||||
public TestChest() : base(0xE43)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private class KeepsContentsChest : Container
|
||||
{
|
||||
public KeepsContentsChest() : base(0xE43)
|
||||
{
|
||||
}
|
||||
|
||||
public override bool ContentsDecay => false;
|
||||
}
|
||||
|
||||
private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true };
|
||||
|
||||
private static TestChest PlaceLockedDownChest(int x)
|
||||
{
|
||||
var chest = new TestChest { Movable = false };
|
||||
chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca);
|
||||
chest.IsLockedDown = true;
|
||||
return chest;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemDroppedIntoLockedDownContainer_IsTracked()
|
||||
{
|
||||
var chest = PlaceLockedDownChest(120);
|
||||
var loot = NewLoot();
|
||||
|
||||
chest.AddItem(loot);
|
||||
|
||||
Assert.True(loot.CanDecay());
|
||||
Assert.True(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemInOrdinaryContainer_IsNotTracked()
|
||||
{
|
||||
var chest = new TestChest();
|
||||
chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca);
|
||||
var loot = NewLoot();
|
||||
|
||||
chest.AddItem(loot);
|
||||
|
||||
Assert.False(loot.CanDecay());
|
||||
Assert.False(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockingAndReleasingContainer_RegistersAndUnregistersContents()
|
||||
{
|
||||
var chest = new TestChest { Movable = false };
|
||||
chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca);
|
||||
var loot = NewLoot();
|
||||
chest.AddItem(loot);
|
||||
|
||||
Assert.False(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.IsLockedDown = true;
|
||||
Assert.True(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.IsSecure = true; // secure containers keep their contents
|
||||
Assert.False(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.IsSecure = false;
|
||||
Assert.True(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.IsLockedDown = false;
|
||||
Assert.False(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainerThatKeepsContents_DoesNotTrackThem()
|
||||
{
|
||||
var board = new KeepsContentsChest { Movable = false };
|
||||
board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca);
|
||||
board.IsLockedDown = true;
|
||||
var piece = NewLoot();
|
||||
|
||||
board.AddItem(piece);
|
||||
|
||||
Assert.False(DecayScheduler.IsRegistered(piece));
|
||||
|
||||
board.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedContainerContents_AreNotTracked()
|
||||
{
|
||||
var chest = PlaceLockedDownChest(124);
|
||||
var bag = new TestChest();
|
||||
chest.AddItem(bag);
|
||||
var loot = NewLoot();
|
||||
bag.AddItem(loot);
|
||||
|
||||
Assert.True(DecayScheduler.IsRegistered(bag));
|
||||
Assert.False(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockedDownItemInsideLockedDownContainer_IsNotTracked()
|
||||
{
|
||||
var chest = PlaceLockedDownChest(125);
|
||||
var loot = NewLoot();
|
||||
chest.AddItem(loot);
|
||||
loot.IsLockedDown = true;
|
||||
|
||||
Assert.False(DecayScheduler.IsRegistered(loot));
|
||||
|
||||
chest.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackedContents_DecayOnSchedule()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var chest = PlaceLockedDownChest(126);
|
||||
var loot = NewLoot();
|
||||
chest.AddItem(loot);
|
||||
|
||||
var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2);
|
||||
for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256))
|
||||
{
|
||||
Core._now = now;
|
||||
DecayScheduler.ProcessTick(now);
|
||||
}
|
||||
|
||||
Assert.True(loot.Deleted, "Contents of a locked-down container must decay.");
|
||||
Assert.False(chest.Deleted, "The locked-down container itself must not decay.");
|
||||
|
||||
chest.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,27 @@ public partial class Container : Item
|
|||
|
||||
public virtual bool IsDecoContainer => !Movable && !IsLockedDown && !IsSecure && Parent == null && !LiftOverride;
|
||||
|
||||
/// <summary>
|
||||
/// True when this container's direct contents decay on their own schedule: a locked-down,
|
||||
/// non-secure container in a house. Containers whose contents are part of the object
|
||||
/// (game boards, aquariums) override this to false.
|
||||
/// </summary>
|
||||
public virtual bool ContentsDecay => IsLockedDown && !IsSecure;
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates decay registration for every direct child. Call when
|
||||
/// <see cref="ContentsDecay" /> may have changed.
|
||||
/// </summary>
|
||||
public void UpdateContentsDecayRegistration()
|
||||
{
|
||||
var items = Items;
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
items[i].UpdateDecayRegistration();
|
||||
}
|
||||
}
|
||||
|
||||
public static int GlobalMaxItems { get; set; } = 125;
|
||||
|
||||
public static int GlobalMaxWeight { get; set; } = 400;
|
||||
|
|
|
|||
|
|
@ -426,8 +426,14 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
get => GetTempFlag(LockedDownFlag);
|
||||
set
|
||||
{
|
||||
if (GetTempFlag(LockedDownFlag) == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetTempFlag(LockedDownFlag, value);
|
||||
InvalidateProperties();
|
||||
OnSecurityChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,8 +443,26 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
get => GetTempFlag(SecureFlag);
|
||||
set
|
||||
{
|
||||
if (GetTempFlag(SecureFlag) == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetTempFlag(SecureFlag, value);
|
||||
InvalidateProperties();
|
||||
OnSecurityChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// Lockdown and secure status decide whether this item and, for a container, its direct
|
||||
// contents are decay-eligible (see CanDecay); re-evaluate both.
|
||||
private void OnSecurityChanged()
|
||||
{
|
||||
UpdateDecayRegistration();
|
||||
|
||||
if (this is Container container)
|
||||
{
|
||||
container.UpdateContentsDecayRegistration();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2351,10 +2375,15 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public bool AtPoint(int x, int y) => m_Location.m_X == x && m_Location.m_Y == y;
|
||||
|
||||
public virtual bool CanDecay() => Decays && Parent == null && Map != Map.Internal;
|
||||
// Ground items decay; so do the direct contents of a container whose ContentsDecay is true
|
||||
// (a locked-down, non-secure house container), unless the content item is itself locked
|
||||
// down or secured. Nested containers do not propagate: only direct children qualify.
|
||||
public virtual bool CanDecay() =>
|
||||
Decays && Map != Map.Internal &&
|
||||
(Parent == null || Parent is Container { ContentsDecay: true } && !IsLockedDown && !IsSecure);
|
||||
|
||||
public virtual bool OnDecay() =>
|
||||
CanDecay() && Region.Find(Location, Map).OnDecay(this);
|
||||
CanDecay() && Region.Find(GetWorldLocation(), Map).OnDecay(this);
|
||||
|
||||
public DateTime ScheduledDecayTime
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,7 +28,17 @@ namespace Server;
|
|||
|
||||
public interface IGenericEntityPersistence
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
int EntityCount { get; }
|
||||
|
||||
void DeserializeIndexes(string savePath, Dictionary<ulong, string> typesDb);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the live entities. Diagnostics only: the dictionary must not be mutated while
|
||||
/// enumerating, so callers snapshot the sequence before doing anything that can add or delete.
|
||||
/// </summary>
|
||||
IEnumerable<ISerializable> EnumerateEntities();
|
||||
}
|
||||
|
||||
public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPersistence, ISlotRangeSource
|
||||
|
|
@ -85,6 +95,16 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
public Dictionary<Serial, T> EntitiesBySerial { get; } = new();
|
||||
|
||||
public int EntityCount => EntitiesBySerial.Count;
|
||||
|
||||
public IEnumerable<ISerializable> EnumerateEntities()
|
||||
{
|
||||
foreach (var entity in EntitiesBySerial.Values)
|
||||
{
|
||||
yield return entity;
|
||||
}
|
||||
}
|
||||
|
||||
public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this(
|
||||
name,
|
||||
priority,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ public abstract class Persistence
|
|||
|
||||
public bool Register() => _registry.Add(this);
|
||||
|
||||
/// <summary>Every registered entity persistence (Items, Mobiles, Guilds, Accounts, ...), in priority order.</summary>
|
||||
public static IEnumerable<IGenericEntityPersistence> EntityPersistences
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var entry in _registry)
|
||||
{
|
||||
if (entry is IGenericEntityPersistence entityPersistence)
|
||||
{
|
||||
yield return entityPersistence;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Unregister() => _registry.Remove(this);
|
||||
|
||||
public static void Load(string path)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Server;
|
||||
using Server.Guilds;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class GuildSerializePurityTests
|
||||
{
|
||||
private static PlayerMobile CreatePlayer()
|
||||
{
|
||||
var m = new PlayerMobile(World.NewMobile);
|
||||
m.DefaultMobileInit();
|
||||
m.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
|
||||
return m;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDoesNotRecalculateFealtyOrMutateState()
|
||||
{
|
||||
var leader = CreatePlayer();
|
||||
var guild = new Guild(leader, "Purity Test Guild", "PTG");
|
||||
var staleFealty = Core.Now - TimeSpan.FromDays(3);
|
||||
guild.LastFealty = staleFealty;
|
||||
|
||||
var writer = new BufferWriter(true);
|
||||
guild.Serialize(writer);
|
||||
|
||||
Assert.Equal(staleFealty, guild.LastFealty);
|
||||
Assert.Same(leader, guild.Leader);
|
||||
|
||||
var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
|
||||
writer.Seek(0, SeekOrigin.Begin);
|
||||
guild.Serialize(writer);
|
||||
var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
|
||||
|
||||
Assert.Equal(first, second);
|
||||
|
||||
writer.Close();
|
||||
guild.Disband();
|
||||
leader.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunMaintenanceRecalculatesStaleFealty()
|
||||
{
|
||||
var leader = CreatePlayer();
|
||||
var guild = new Guild(leader, "Maintenance Test Guild", "MTG");
|
||||
guild.LastFealty = Core.Now - TimeSpan.FromDays(3);
|
||||
|
||||
guild.RunMaintenance();
|
||||
|
||||
Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1));
|
||||
|
||||
guild.Disband();
|
||||
leader.Delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class HouseDecayPurityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadingDecayLevelDoesNotStampLastRefreshed()
|
||||
{
|
||||
var decayEnabled = BaseHouse.DecayEnabled;
|
||||
BaseHouse.DecayEnabled = false; // every house is Ageless, the branch that used to restamp on read
|
||||
|
||||
var owner = new PlayerMobile(World.NewMobile);
|
||||
owner.DefaultMobileInit();
|
||||
owner.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca);
|
||||
|
||||
var house = new SmallOldHouse(owner, 0x64);
|
||||
|
||||
try
|
||||
{
|
||||
house.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca);
|
||||
|
||||
var placed = Core.Now - TimeSpan.FromDays(2);
|
||||
house.LastRefreshed = placed;
|
||||
|
||||
Assert.False(house.CanDecay);
|
||||
Assert.Equal(DecayLevel.Ageless, house.DecayLevel);
|
||||
Assert.Equal(placed, house.LastRefreshed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
house.Delete();
|
||||
owner.Delete();
|
||||
BaseHouse.DecayEnabled = decayEnabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
152
Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
Normal file
152
Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class SaveStabilityTests
|
||||
{
|
||||
// Serializes the current time, the classic way a record drifts between saves with no mutation.
|
||||
private sealed class ClockStampedItem : Item
|
||||
{
|
||||
public ClockStampedItem() : base(0x1F03)
|
||||
{
|
||||
}
|
||||
|
||||
public ClockStampedItem(Serial serial) : base(serial)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Serialize(IGenericWriter writer)
|
||||
{
|
||||
base.Serialize(writer);
|
||||
writer.Write(Core.Now);
|
||||
}
|
||||
|
||||
public override void Deserialize(IGenericReader reader)
|
||||
{
|
||||
base.Deserialize(reader);
|
||||
reader.ReadDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CreatureStub : BaseCreature
|
||||
{
|
||||
public CreatureStub() : base(AIType.AI_Animal, FightMode.Closest, 10, 1)
|
||||
{
|
||||
Body = 0xEE; // rat
|
||||
}
|
||||
|
||||
public CreatureStub(Serial serial) : base(serial)
|
||||
{
|
||||
}
|
||||
|
||||
// NPCSpeeds is not configured in the test fixture; the AIType ctor would hit the empty table.
|
||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||
{
|
||||
activeSpeed = 0.2;
|
||||
passiveSpeed = 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableEntitiesHashIdenticallyAfterTimePasses()
|
||||
{
|
||||
var bag = new Bag();
|
||||
bag.DropItem(new Gold(100));
|
||||
bag.DropItem(new Dagger());
|
||||
bag.MoveToWorld(new Point3D(1450, 1450, 0), Map.Felucca);
|
||||
|
||||
var creature = new CreatureStub();
|
||||
creature.MoveToWorld(new Point3D(1451, 1450, 0), Map.Felucca);
|
||||
|
||||
var entities = new List<ISerializable> { bag, creature };
|
||||
entities.AddRange(bag.Items);
|
||||
|
||||
var now = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var captured = SaveStability.CaptureHashes(entities);
|
||||
Core._now = now + TimeSpan.FromMinutes(5);
|
||||
|
||||
var report = SaveStability.Compare(entities, captured);
|
||||
|
||||
Assert.Equal(entities.Count, report.Checked);
|
||||
Assert.Equal(0, report.Changed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = now;
|
||||
bag.Delete();
|
||||
creature.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockDerivedRecordIsReportedByType()
|
||||
{
|
||||
var item = new ClockStampedItem();
|
||||
item.MoveToWorld(new Point3D(1452, 1450, 0), Map.Felucca);
|
||||
|
||||
var now = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var entities = new List<ISerializable> { item };
|
||||
var captured = SaveStability.CaptureHashes(entities);
|
||||
|
||||
// Same tick: the drift is invisible, which is why the command waits in real time.
|
||||
Assert.Equal(0, SaveStability.Compare(entities, captured).Changed);
|
||||
|
||||
Core._now = now + TimeSpan.FromMinutes(1);
|
||||
var report = SaveStability.Compare(entities, captured);
|
||||
|
||||
Assert.Equal(1, report.Changed);
|
||||
Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Changed);
|
||||
Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Total);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = now;
|
||||
item.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotCoversEveryRegisteredEntityPersistence()
|
||||
{
|
||||
var item = new Bag();
|
||||
item.MoveToWorld(new Point3D(1453, 1450, 0), Map.Felucca);
|
||||
var mobile = new CreatureStub();
|
||||
mobile.MoveToWorld(new Point3D(1454, 1450, 0), Map.Felucca);
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = SaveStability.SnapshotEntities();
|
||||
|
||||
Assert.Contains(item, snapshot);
|
||||
Assert.Contains(mobile, snapshot);
|
||||
|
||||
var names = new List<string>();
|
||||
foreach (var persistence in Persistence.EntityPersistences)
|
||||
{
|
||||
names.Add(persistence.Name);
|
||||
}
|
||||
|
||||
Assert.Contains("Items", names);
|
||||
Assert.Contains("Mobiles", names);
|
||||
Assert.Contains("Guilds", names);
|
||||
}
|
||||
finally
|
||||
{
|
||||
item.Delete();
|
||||
mobile.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
318
Projects/UOContent/Commands/SaveStabilityCommand.cs
Normal file
318
Projects/UOContent/Commands/SaveStabilityCommand.cs
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Measures whether entities serialize to the same bytes over time when nothing changed them.
|
||||
/// Hashes every entity of every registered entity persistence, waits, hashes again, and reports
|
||||
/// per type how many records changed. Both passes are chunked across ticks so the loop never
|
||||
/// stalls, and the wait spans real time so anything derived from <see cref="Core.Now" /> (which
|
||||
/// is frozen within a tick) shows up. On an idle shard the only legitimate churn is NPC movement
|
||||
/// and regeneration; a static type with a high changed fraction is a serialization bug.
|
||||
/// </summary>
|
||||
public static class SaveStability
|
||||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(SaveStability));
|
||||
|
||||
private const int DefaultDelaySeconds = 60;
|
||||
private static readonly TimeSpan TickBudget = TimeSpan.FromMilliseconds(20);
|
||||
|
||||
private static Run _current;
|
||||
|
||||
public sealed class TypeStats
|
||||
{
|
||||
public int Total;
|
||||
public int Changed;
|
||||
}
|
||||
|
||||
public sealed record SaveStabilityReport(
|
||||
int Checked,
|
||||
int Changed,
|
||||
Dictionary<Type, TypeStats> ByType
|
||||
);
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
CommandSystem.Register("SaveStability", AccessLevel.Administrator, SaveStability_OnCommand);
|
||||
}
|
||||
|
||||
[Usage("SaveStability [delaySeconds=60] [sampleStride=1] | SaveStability cancel")]
|
||||
[Description("Hashes every entity, waits, hashes again, and reports the types whose serialized bytes changed.")]
|
||||
private static void SaveStability_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
var from = e.Mobile;
|
||||
|
||||
if (e.Length > 0 && e.GetString(0).InsensitiveEquals("cancel"))
|
||||
{
|
||||
if (_current != null)
|
||||
{
|
||||
_current.Cancel();
|
||||
_current = null;
|
||||
from.SendMessage("Save stability check cancelled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
from.SendMessage("No save stability check is running.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (_current != null)
|
||||
{
|
||||
from.SendMessage("A save stability check is already running. Use [SaveStability cancel to stop it.");
|
||||
return;
|
||||
}
|
||||
|
||||
var delaySeconds = e.Length > 0 ? e.GetInt32(0) : DefaultDelaySeconds;
|
||||
var stride = e.Length > 1 ? e.GetInt32(1) : 1;
|
||||
|
||||
if (delaySeconds < 1 || stride < 1)
|
||||
{
|
||||
from.SendMessage("Usage: [SaveStability [delaySeconds] [sampleStride]");
|
||||
return;
|
||||
}
|
||||
|
||||
_current = new Run(from, TimeSpan.FromSeconds(delaySeconds), stride);
|
||||
_current.Start();
|
||||
}
|
||||
|
||||
/// <summary>Snapshots every entity of every registered entity persistence, taking every Nth.</summary>
|
||||
public static List<ISerializable> SnapshotEntities(int stride = 1)
|
||||
{
|
||||
var list = new List<ISerializable>();
|
||||
var i = 0;
|
||||
|
||||
foreach (var persistence in Persistence.EntityPersistences)
|
||||
{
|
||||
foreach (var entity in persistence.EnumerateEntities())
|
||||
{
|
||||
if (i++ % stride == 0)
|
||||
{
|
||||
list.Add(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Hashes the serialized bytes of every entity in order; deleted entities hash to 0.</summary>
|
||||
public static ulong[] CaptureHashes(List<ISerializable> entities)
|
||||
{
|
||||
var hashes = new ulong[entities.Count];
|
||||
var writer = new BufferWriter(true);
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
hashes[i] = Hash(entities[i], writer);
|
||||
}
|
||||
|
||||
writer.Close();
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/// <summary>Re-hashes every entity and reports, per type, how many differ from the captured hashes.</summary>
|
||||
public static SaveStabilityReport Compare(List<ISerializable> entities, ulong[] captured)
|
||||
{
|
||||
var writer = new BufferWriter(true);
|
||||
var report = new SaveStabilityReport(0, 0, new Dictionary<Type, TypeStats>());
|
||||
var checkedCount = 0;
|
||||
var changed = 0;
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
if (entity.Deleted || captured[i] == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
var type = entity.GetType();
|
||||
|
||||
if (!report.ByType.TryGetValue(type, out var stats))
|
||||
{
|
||||
report.ByType[type] = stats = new TypeStats();
|
||||
}
|
||||
|
||||
stats.Total++;
|
||||
|
||||
if (Hash(entity, writer) != captured[i])
|
||||
{
|
||||
stats.Changed++;
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
writer.Close();
|
||||
return report with { Checked = checkedCount, Changed = changed };
|
||||
}
|
||||
|
||||
private static ulong Hash(ISerializable entity, BufferWriter writer)
|
||||
{
|
||||
if (entity.Deleted)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
writer.Seek(0, SeekOrigin.Begin);
|
||||
entity.Serialize(writer);
|
||||
return HashUtility.ComputeHash64(writer.Buffer.AsSpan(0, (int)writer.Position));
|
||||
}
|
||||
|
||||
// Drives capture -> wait -> compare across ticks under a fixed time budget per tick.
|
||||
private sealed class Run
|
||||
{
|
||||
private readonly Mobile _from;
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly int _stride;
|
||||
private readonly BufferWriter _writer = new(true);
|
||||
|
||||
private List<ISerializable> _entities;
|
||||
private ulong[] _hashes;
|
||||
private SaveStabilityReport _report;
|
||||
private int _index;
|
||||
private bool _comparing;
|
||||
private Timer _timer;
|
||||
|
||||
public Run(Mobile from, TimeSpan delay, int stride)
|
||||
{
|
||||
_from = from;
|
||||
_delay = delay;
|
||||
_stride = stride;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_entities = SnapshotEntities(_stride);
|
||||
_hashes = new ulong[_entities.Count];
|
||||
|
||||
_from.SendMessage($"Save stability: hashing {_entities.Count} entities across ticks...");
|
||||
_timer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.Zero, Step);
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
_timer?.Stop();
|
||||
_timer = null;
|
||||
_writer.Close();
|
||||
}
|
||||
|
||||
private void Step()
|
||||
{
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
|
||||
while (_index < _entities.Count)
|
||||
{
|
||||
var entity = _entities[_index];
|
||||
|
||||
if (_comparing)
|
||||
{
|
||||
CompareOne(entity, _index);
|
||||
}
|
||||
else
|
||||
{
|
||||
_hashes[_index] = Hash(entity, _writer);
|
||||
}
|
||||
|
||||
_index++;
|
||||
|
||||
if (Stopwatch.GetElapsedTime(started) >= TickBudget)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
|
||||
if (!_comparing)
|
||||
{
|
||||
_from.SendMessage($"Save stability: captured. Comparing again in {_delay.TotalSeconds:F0}s.");
|
||||
_comparing = true;
|
||||
_index = 0;
|
||||
_report = new SaveStabilityReport(0, 0, new Dictionary<Type, TypeStats>());
|
||||
_timer = Timer.DelayCall(_delay, TimeSpan.Zero, Step);
|
||||
return;
|
||||
}
|
||||
|
||||
Finish();
|
||||
}
|
||||
|
||||
private void CompareOne(ISerializable entity, int index)
|
||||
{
|
||||
if (entity.Deleted || _hashes[index] == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var type = entity.GetType();
|
||||
|
||||
if (!_report.ByType.TryGetValue(type, out var stats))
|
||||
{
|
||||
_report.ByType[type] = stats = new TypeStats();
|
||||
}
|
||||
|
||||
stats.Total++;
|
||||
|
||||
if (Hash(entity, _writer) != _hashes[index])
|
||||
{
|
||||
stats.Changed++;
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
_writer.Close();
|
||||
_current = null;
|
||||
|
||||
var checkedCount = 0;
|
||||
var changed = 0;
|
||||
var ranked = new List<KeyValuePair<Type, TypeStats>>();
|
||||
|
||||
foreach (var pair in _report.ByType)
|
||||
{
|
||||
checkedCount += pair.Value.Total;
|
||||
changed += pair.Value.Changed;
|
||||
|
||||
if (pair.Value.Changed > 0)
|
||||
{
|
||||
ranked.Add(pair);
|
||||
}
|
||||
}
|
||||
|
||||
ranked.Sort(static (a, b) => b.Value.Changed.CompareTo(a.Value.Changed));
|
||||
|
||||
_from.SendMessage($"Save stability: {changed} of {checkedCount} entities changed over {_delay.TotalSeconds:F0}s.");
|
||||
logger.Information(
|
||||
"Save stability: {Changed} of {Checked} entities changed over {Delay}s ({Types} types)",
|
||||
changed,
|
||||
checkedCount,
|
||||
_delay.TotalSeconds,
|
||||
ranked.Count
|
||||
);
|
||||
|
||||
var shown = 0;
|
||||
foreach (var (type, stats) in ranked)
|
||||
{
|
||||
var percent = stats.Changed * 100.0 / stats.Total;
|
||||
logger.Information(" {Type}: {Changed}/{Total} ({Percent:F1}%)", type.FullName, stats.Changed, stats.Total, percent);
|
||||
|
||||
if (shown++ < 25)
|
||||
{
|
||||
_from.SendMessage($" {type.Name}: {stats.Changed}/{stats.Total} ({percent:F1}%)");
|
||||
}
|
||||
}
|
||||
|
||||
if (ranked.Count > 25)
|
||||
{
|
||||
_from.SendMessage($" ...{ranked.Count - 25} more types in the log.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ namespace Server.Items
|
|||
[SerializationGenerator(4, false)]
|
||||
public partial class Aquarium : BaseAddonContainer
|
||||
{
|
||||
public override bool ContentsDecay => false; // fish and decorations are part of the aquarium
|
||||
|
||||
public static readonly TimeSpan EvaluationInterval = TimeSpan.FromDays(1);
|
||||
|
||||
private static readonly Type[] m_Decorations =
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ namespace Server.Items
|
|||
[SerializationGenerator(0, false)]
|
||||
public partial class FishBowl : BaseContainer
|
||||
{
|
||||
public override bool ContentsDecay => false; // the fish is part of the bowl
|
||||
|
||||
[Constructible]
|
||||
public FishBowl() : base(0x241C)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ namespace Server.Items;
|
|||
[SerializationGenerator(2, false)]
|
||||
public abstract partial class BaseBoard : Container, ISecurable
|
||||
{
|
||||
public override bool ContentsDecay => false; // pieces are part of the board
|
||||
|
||||
[SerializedIgnoreDupe]
|
||||
[SerializableField(0)]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -53,16 +53,15 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public BraceletOfBinding Bound
|
||||
{
|
||||
get
|
||||
get => _bound?.Deleted != false ? null : _bound;
|
||||
set
|
||||
{
|
||||
if (_bound?.Deleted == true)
|
||||
if (_bound != value)
|
||||
{
|
||||
_bound = null;
|
||||
_bound = value;
|
||||
this.MarkDirty();
|
||||
}
|
||||
|
||||
return _bound;
|
||||
}
|
||||
set => _bound = value;
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -498,25 +498,22 @@ namespace Server.Guilds
|
|||
}
|
||||
}
|
||||
|
||||
public class WarTimer : Timer
|
||||
public class GuildMaintenanceTimer : Timer
|
||||
{
|
||||
public WarTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0))
|
||||
public GuildMaintenanceTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0))
|
||||
{
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (Guild.NewGuildSystem)
|
||||
{
|
||||
new WarTimer().Start();
|
||||
}
|
||||
new GuildMaintenanceTimer().Start();
|
||||
}
|
||||
|
||||
protected override void OnTick()
|
||||
{
|
||||
foreach (var g in World.Guilds.Values)
|
||||
{
|
||||
(g as Guild)?.CheckExpiredWars();
|
||||
(g as Guild)?.RunMaintenance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1100,8 +1097,18 @@ namespace Server.Guilds
|
|||
list.TrimExcess();
|
||||
}
|
||||
|
||||
public override void Serialize(IGenericWriter writer)
|
||||
/// <summary>
|
||||
/// Periodic bookkeeping that used to ride on every world save: the daily fealty
|
||||
/// recalculation, war expiry, and the alliance leadership check. Saves must be pure,
|
||||
/// so this runs from <see cref="GuildMaintenanceTimer"/> instead.
|
||||
/// </summary>
|
||||
public void RunMaintenance()
|
||||
{
|
||||
if (Disbanded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (LastFealty + TimeSpan.FromDays(1.0) < Core.Now)
|
||||
{
|
||||
CalculateGuildmaster();
|
||||
|
|
@ -1110,7 +1117,10 @@ namespace Server.Guilds
|
|||
CheckExpiredWars();
|
||||
|
||||
Alliance?.CheckLeader();
|
||||
}
|
||||
|
||||
public override void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.Write(5); // version
|
||||
|
||||
writer.Write(PendingWars.Count);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ namespace Server.Multis
|
|||
private DecayLevel m_CurrentStage;
|
||||
|
||||
private DecayLevel m_LastDecayLevel;
|
||||
private bool _wasUndecayable; // not serialized: decay tick state only
|
||||
|
||||
private Mobile m_Owner;
|
||||
|
||||
|
|
@ -198,19 +199,58 @@ namespace Server.Multis
|
|||
{
|
||||
get
|
||||
{
|
||||
DecayLevel result;
|
||||
|
||||
if (!CanDecay)
|
||||
{
|
||||
return DecayLevel.Ageless;
|
||||
}
|
||||
|
||||
if (DynamicDecay.Enabled)
|
||||
{
|
||||
var stage = m_CurrentStage;
|
||||
|
||||
if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0))
|
||||
{
|
||||
return DecayLevel.DemolitionPending;
|
||||
}
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
return GetOldDecayLevel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances decay bookkeeping. Runs from the decay tick, never from a read: a getter that
|
||||
/// writes persisted fields makes every house record change on every read.
|
||||
/// </summary>
|
||||
public virtual void UpdateDecay()
|
||||
{
|
||||
if (!CanDecay)
|
||||
{
|
||||
if (DynamicDecay.Enabled && m_CurrentStage != DecayLevel.Ageless)
|
||||
{
|
||||
ResetDynamicDecay();
|
||||
}
|
||||
|
||||
_wasUndecayable = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_wasUndecayable)
|
||||
{
|
||||
// Leaving the undecayable state counts as a refresh, which the old getter
|
||||
// guaranteed by restamping LastRefreshed on every read while undecayable.
|
||||
_wasUndecayable = false;
|
||||
LastRefreshed = Core.Now;
|
||||
|
||||
if (DynamicDecay.Enabled)
|
||||
{
|
||||
ResetDynamicDecay();
|
||||
}
|
||||
|
||||
LastRefreshed = Core.Now;
|
||||
result = DecayLevel.Ageless;
|
||||
}
|
||||
else if (DynamicDecay.Enabled)
|
||||
|
||||
if (DynamicDecay.Enabled)
|
||||
{
|
||||
var stage = m_CurrentStage;
|
||||
|
||||
|
|
@ -218,32 +258,19 @@ namespace Server.Multis
|
|||
{
|
||||
SetDynamicDecay(++stage);
|
||||
}
|
||||
|
||||
if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0))
|
||||
{
|
||||
result = DecayLevel.DemolitionPending;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = stage;
|
||||
}
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
var level = DecayLevel;
|
||||
|
||||
if (level != m_LastDecayLevel)
|
||||
{
|
||||
m_LastDecayLevel = level;
|
||||
|
||||
if (Sign?.GettingProperties == false)
|
||||
{
|
||||
result = GetOldDecayLevel();
|
||||
Sign.InvalidateProperties();
|
||||
}
|
||||
|
||||
if (result != m_LastDecayLevel)
|
||||
{
|
||||
m_LastDecayLevel = result;
|
||||
|
||||
if (Sign?.GettingProperties == false)
|
||||
{
|
||||
Sign.InvalidateProperties();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -582,6 +609,7 @@ namespace Server.Multis
|
|||
return false;
|
||||
}
|
||||
|
||||
UpdateDecay();
|
||||
var oldLevel = DecayLevel;
|
||||
|
||||
LastRefreshed = Core.Now;
|
||||
|
|
@ -598,6 +626,8 @@ namespace Server.Multis
|
|||
|
||||
public virtual bool CheckDecay()
|
||||
{
|
||||
UpdateDecay();
|
||||
|
||||
if (!Deleted && DecayLevel == DecayLevel.Collapsed)
|
||||
{
|
||||
Timer.StartTimer(Decay_Sandbox);
|
||||
|
|
@ -2969,28 +2999,6 @@ namespace Server.Multis
|
|||
|
||||
writer.Write(MaxLockDowns);
|
||||
writer.Write(MaxSecures);
|
||||
|
||||
// Items in locked down containers that aren't locked down themselves must decay!
|
||||
for (var i = 0; i < LockDowns.Count; ++i)
|
||||
{
|
||||
var item = LockDowns[i];
|
||||
|
||||
if (item is Container cont && !(cont is BaseBoard or Aquarium or FishBowl))
|
||||
{
|
||||
var children = cont.Items;
|
||||
|
||||
for (var j = 0; j < children.Count; ++j)
|
||||
{
|
||||
var child = children[j];
|
||||
|
||||
if (child.Decays && !child.IsLockedDown && !child.IsSecure &&
|
||||
child.LastMoved + child.DecayTime <= Core.Now)
|
||||
{
|
||||
Timer.StartTimer(child.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Deserialize(IGenericReader reader)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue