ModernUO/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs
Kamron Batman 2935eafe24
feat: convert all delta-time serialization to anchored time (#2589)
## Summary

Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save.

The answer to "is it possible everywhere": **yes** — including the one case that looked impossible.

## The GenericPersistence problem, solved

`GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected.

## Converted

- **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`.
- **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`.
- **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version.

**Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them.

## Verification

- Build 0 errors / 0 warnings; **837 + 708 tests green**.
- Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched.
- **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties).

## Notes for review

- `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes.
- BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes.

## Enforcement

`WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract.
2026-08-22 19:34:43 -07:00

453 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using Server.Items;
using Server.Logging;
namespace Server.Engines.Stealables;
public class StealableArtifacts : GenericPersistence
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(StealableArtifacts));
private static StealableArtifacts _stealableArtifactsPersistence;
private static bool _enabled;
private static Type[] _typesOfEntries;
private static StealableInstance[] _artifacts;
private static Timer _respawnTimer;
private static Dictionary<Item, StealableInstance> _table;
public static void Configure()
{
_stealableArtifactsPersistence = new StealableArtifacts();
CommandSystem.Register("GenStealArties", AccessLevel.Developer, GenStealArties_OnCommand);
CommandSystem.Register("RemoveStealArties", AccessLevel.Developer, RemoveStealArties_OnCommand);
}
public StealableArtifacts() : base("StealableArtifacts", 10)
{
}
private static void RemoveStealableArtifacts()
{
_enabled = false;
_respawnTimer?.Stop();
_respawnTimer = null;
if (_artifacts?.Length > 0)
{
foreach (var si in _artifacts)
{
var item = si.Item;
if (item.Deleted == false && !item.Movable && item.Parent == null)
{
item.Delete();
}
}
}
_artifacts = null;
}
private static void CreateStealableArtifacts()
{
_enabled = true;
_artifacts = new StealableInstance[Entries.Length];
_table ??= new Dictionary<Item, StealableInstance>(Entries.Length);
for (var i = 0; i < Entries.Length; i++)
{
_artifacts[i] = new StealableInstance(Entries[i]);
}
_respawnTimer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMinutes(15.0), CheckRespawn);
}
public static StealableEntry[] Entries { get; } =
{
// Doom - Artifact rarity 1
new(Map.Malas, new Point3D(317, 56, -1), 72, 108, typeof(RockArtifact)),
new(Map.Malas, new Point3D(360, 31, 8), 72, 108, typeof(SkullCandleArtifact)),
new(Map.Malas, new Point3D(369, 372, -1), 72, 108, typeof(BottleArtifact)),
new(Map.Malas, new Point3D(378, 372, 0), 72, 108, typeof(DamagedBooksArtifact)),
// Doom - Artifact rarity 2
new(Map.Malas, new Point3D(432, 16, -1), 144, 216, typeof(StretchedHideArtifact)),
new(Map.Malas, new Point3D(489, 9, 0), 144, 216, typeof(BrazierArtifact)),
// Doom - Artifact rarity 3
new(Map.Malas, new Point3D(471, 96, -1), 288, 432, typeof(LampPostArtifact), GetLampPostHue()),
new(Map.Malas, new Point3D(421, 198, 2), 288, 432, typeof(BooksNorthArtifact)),
new(Map.Malas, new Point3D(431, 189, -1), 288, 432, typeof(BooksWestArtifact)),
new(Map.Malas, new Point3D(435, 196, -1), 288, 432, typeof(BooksFaceDownArtifact)),
// Doom - Artifact rarity 5
new(Map.Malas, new Point3D(447, 9, 8), 1152, 1728, typeof(StuddedLeggingsArtifact)),
new(Map.Malas, new Point3D(423, 28, 0), 1152, 1728, typeof(EggCaseArtifact)),
new(Map.Malas, new Point3D(347, 44, 4), 1152, 1728, typeof(SkinnedGoatArtifact)),
new(Map.Malas, new Point3D(497, 57, -1), 1152, 1728, typeof(GruesomeStandardArtifact)),
new(Map.Malas, new Point3D(381, 375, 11), 1152, 1728, typeof(BloodyWaterArtifact)),
new(Map.Malas, new Point3D(489, 369, 2), 1152, 1728, typeof(TarotCardsArtifact)),
new(Map.Malas, new Point3D(497, 369, 5), 1152, 1728, typeof(BackpackArtifact)),
// Doom - Artifact rarity 7
new(Map.Malas, new Point3D(475, 23, 4), 4608, 6912, typeof(StuddedTunicArtifact)),
new(Map.Malas, new Point3D(423, 28, 0), 4608, 6912, typeof(CocoonArtifact)),
// Doom - Artifact rarity 8
new(Map.Malas, new Point3D(354, 36, -1), 9216, 13824, typeof(SkinnedDeerArtifact)),
// Doom - Artifact rarity 9
new(Map.Malas, new Point3D(433, 11, -1), 18432, 27648, typeof(SaddleArtifact)),
new(Map.Malas, new Point3D(403, 31, 4), 18432, 27648, typeof(LeatherTunicArtifact)),
// Doom - Artifact rarity 10
new(Map.Malas, new Point3D(257, 70, -2), 36864, 55296, typeof(ZyronicClaw)),
new(Map.Malas, new Point3D(354, 176, 7), 36864, 55296, typeof(TitansHammer)),
new(Map.Malas, new Point3D(369, 389, -1), 36864, 55296, typeof(BladeOfTheRighteous)),
new(Map.Malas, new Point3D(467, 92, 4), 36864, 55296, typeof(InquisitorsResolution)),
// Doom - Artifact rarity 12
new(Map.Malas, new Point3D(487, 364, -1), 147456, 221184, typeof(RuinedPaintingArtifact)),
// Yomotsu Mines - Artifact rarity 1
new(Map.Malas, new Point3D(18, 110, -1), 72, 108, typeof(Basket1Artifact)),
new(Map.Malas, new Point3D(66, 114, -1), 72, 108, typeof(Basket2Artifact)),
// Yomotsu Mines - Artifact rarity 2
new(Map.Malas, new Point3D(63, 12, 11), 144, 216, typeof(Basket4Artifact)),
new(Map.Malas, new Point3D(5, 29, -1), 144, 216, typeof(Basket5NorthArtifact)),
new(Map.Malas, new Point3D(30, 81, 3), 144, 216, typeof(Basket5WestArtifact)),
// Yomotsu Mines - Artifact rarity 3
new(Map.Malas, new Point3D(115, 7, -1), 288, 432, typeof(Urn1Artifact)),
new(Map.Malas, new Point3D(85, 13, -1), 288, 432, typeof(Urn2Artifact)),
new(Map.Malas, new Point3D(110, 53, -1), 288, 432, typeof(Sculpture1Artifact)),
new(Map.Malas, new Point3D(108, 37, -1), 288, 432, typeof(Sculpture2Artifact)),
new(Map.Malas, new Point3D(121, 14, -1), 288, 432, typeof(TeapotNorthArtifact)),
new(Map.Malas, new Point3D(121, 115, -1), 288, 432, typeof(TeapotWestArtifact)),
new(Map.Malas, new Point3D(84, 40, -1), 288, 432, typeof(TowerLanternArtifact)),
// Yomotsu Mines - Artifact rarity 9
new(Map.Malas, new Point3D(94, 7, -1), 18432, 27648, typeof(ManStatuetteSouthArtifact)),
// Fan Dancer's Dojo - Artifact rarity 1
new(Map.Malas, new Point3D(113, 640, -2), 72, 108, typeof(Basket3NorthArtifact)),
new(Map.Malas, new Point3D(102, 355, -1), 72, 108, typeof(Basket3WestArtifact)),
// Fan Dancer's Dojo - Artifact rarity 2
new(Map.Malas, new Point3D(99, 370, -1), 144, 216, typeof(Basket6Artifact)),
new(Map.Malas, new Point3D(100, 357, -1), 144, 216, typeof(ZenRock1Artifact)),
// Fan Dancer's Dojo - Artifact rarity 3
new(Map.Malas, new Point3D(73, 473, -1), 288, 432, typeof(FanNorthArtifact)),
new(Map.Malas, new Point3D(99, 372, -1), 288, 432, typeof(FanWestArtifact)),
new(Map.Malas, new Point3D(92, 326, -1), 288, 432, typeof(BowlsVerticalArtifact)),
new(Map.Malas, new Point3D(97, 470, -1), 288, 432, typeof(ZenRock2Artifact)),
new(Map.Malas, new Point3D(103, 691, -1), 288, 432, typeof(ZenRock3Artifact)),
// Fan Dancer's Dojo - Artifact rarity 4
new(Map.Malas, new Point3D(103, 336, 4), 576, 864, typeof(Painting1NorthArtifact)),
new(Map.Malas, new Point3D(59, 381, 4), 576, 864, typeof(Painting1WestArtifact)),
new(Map.Malas, new Point3D(84, 401, 2), 576, 864, typeof(Painting2NorthArtifact)),
new(Map.Malas, new Point3D(59, 392, 2), 576, 864, typeof(Painting2WestArtifact)),
new(Map.Malas, new Point3D(107, 483, -1), 576, 864, typeof(TripleFanNorthArtifact)),
new(Map.Malas, new Point3D(50, 475, -1), 576, 864, typeof(TripleFanWestArtifact)),
new(Map.Malas, new Point3D(107, 460, -1), 576, 864, typeof(BowlArtifact)),
new(Map.Malas, new Point3D(90, 502, -1), 576, 864, typeof(CupsArtifact)),
new(Map.Malas, new Point3D(107, 688, -1), 576, 864, typeof(BowlsHorizontalArtifact)),
new(Map.Malas, new Point3D(112, 676, -1), 576, 864, typeof(SakeArtifact)),
// Fan Dancer's Dojo - Artifact rarity 5
new(Map.Malas, new Point3D(135, 614, -1), 1152, 1728, typeof(SwordDisplay1NorthArtifact)),
new(Map.Malas, new Point3D(50, 482, -1), 1152, 1728, typeof(SwordDisplay1WestArtifact)),
new(Map.Malas, new Point3D(119, 672, -1), 1152, 1728, typeof(Painting3Artifact)),
// Fan Dancer's Dojo - Artifact rarity 6
new(Map.Malas, new Point3D(90, 326, -1), 2304, 3456, typeof(Painting4NorthArtifact)),
new(Map.Malas, new Point3D(99, 354, -1), 2304, 3456, typeof(Painting4WestArtifact)),
new(Map.Malas, new Point3D(179, 652, -1), 2304, 3456, typeof(SwordDisplay2NorthArtifact)),
new(Map.Malas, new Point3D(118, 627, -1), 2304, 3456, typeof(SwordDisplay2WestArtifact)),
// Fan Dancer's Dojo - Artifact rarity 7
new(Map.Malas, new Point3D(90, 483, -1), 4608, 6912, typeof(FlowersArtifact)),
// Fan Dancer's Dojo - Artifact rarity 8
new(Map.Malas, new Point3D(71, 562, -1), 9216, 13824, typeof(DolphinLeftArtifact)),
new(Map.Malas, new Point3D(102, 677, -1), 9216, 13824, typeof(DolphinRightArtifact)),
new(Map.Malas, new Point3D(61, 499, 0), 9216, 13824, typeof(SwordDisplay3SouthArtifact)),
new(Map.Malas, new Point3D(182, 669, -1), 9216, 13824, typeof(SwordDisplay3EastArtifact)),
new(Map.Malas, new Point3D(162, 647, -1), 9216, 13824, typeof(SwordDisplay4WestArtifact)),
new(Map.Malas, new Point3D(124, 624, 0), 9216, 13824, typeof(Painting5NorthArtifact)),
new(Map.Malas, new Point3D(146, 649, 2), 9216, 13824, typeof(Painting5WestArtifact)),
// Fan Dancer's Dojo - Artifact rarity 9
new(Map.Malas, new Point3D(100, 488, -1), 18432, 27648, typeof(SwordDisplay4NorthArtifact)),
new(Map.Malas, new Point3D(175, 606, 0), 18432, 27648, typeof(SwordDisplay5NorthArtifact)),
new(Map.Malas, new Point3D(157, 608, -1), 18432, 27648, typeof(SwordDisplay5WestArtifact)),
new(Map.Malas, new Point3D(187, 643, 1), 18432, 27648, typeof(Painting6NorthArtifact)),
new(Map.Malas, new Point3D(146, 623, 1), 18432, 27648, typeof(Painting6WestArtifact)),
new(Map.Malas, new Point3D(178, 629, -1), 18432, 27648, typeof(ManStatuetteEastArtifact)),
// Stygian Abyss (Ter Mur) - Artifact rarity 5
new(Map.TerMur, new Point3D(843, 665, 27), 1152, 1728, typeof(ValkyriesGlaive))
};
public static Type[] TypesOfEntries
{
get
{
if (_typesOfEntries == null)
{
_typesOfEntries = new Type[Entries.Length];
for (var i = 0; i < Entries.Length; i++)
{
_typesOfEntries[i] = Entries[i].Type;
}
}
return _typesOfEntries;
}
}
private static int GetLampPostHue() =>
Utility.RandomDouble() < 0.9 ? 0 : Utility.RandomList(0x455, 0x47E, 0x482, 0x486, 0x48F, 0x4F2, 0x58C, 0x66C);
[Usage("GenStealArties"), Description("Generates the stealable artifacts spawner.")]
private static void GenStealArties_OnCommand(CommandEventArgs args)
{
var from = args.Mobile;
if (_enabled)
{
from.SendMessage("Stealable artifacts spawner already present.");
return;
}
CreateStealableArtifacts();
from.SendMessage("Stealable artifacts spawner generated.");
}
[Usage("RemoveStealArties")]
[Description("Removes the stealable artifacts spawner and every not yet stolen stealable artifacts.")]
private static void RemoveStealArties_OnCommand(CommandEventArgs args)
{
var from = args.Mobile;
if (!_enabled)
{
from.SendMessage("Stealable artifacts spawner not present.");
return;
}
RemoveStealableArtifacts();
from.SendMessage("Stealable artifacts spawner removed.");
}
public static StealableInstance GetStealableInstance(Item item)
{
if (_enabled && _table.TryGetValue(item, out var value))
{
return value;
}
return null;
}
public static void CheckRespawn()
{
foreach (var si in _artifacts)
{
si.CheckRespawn();
}
}
public override void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(2); // version
writer.Write(_enabled);
if (_enabled)
{
writer.WriteEncodedInt(_artifacts.Length);
for (var i = 0; i < _artifacts.Length; i++)
{
var si = _artifacts[i];
writer.Write(si.Item);
writer.WriteAnchoredTime(si.NextRespawn);
}
}
}
public override void Deserialize(IGenericReader reader)
{
var version = reader.ReadEncodedInt();
_enabled = version > 0 && reader.ReadBool();
if (_enabled)
{
_artifacts = new StealableInstance[Entries.Length];
_table = new Dictionary<Item, StealableInstance>(Entries.Length);
var length = reader.ReadEncodedInt();
for (var i = 0; i < length; i++)
{
var item = reader.ReadEntity<Item>();
var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
if (i < _artifacts.Length)
{
var si = new StealableInstance(Entries[i], item, nextRespawn);
_artifacts[i] = si;
if (si.Item != null)
{
_table[si.Item] = si;
}
}
}
for (var i = length; i < Entries.Length; i++)
{
_artifacts[i] = new StealableInstance(Entries[i]);
}
_enabled = _artifacts.Length > 0;
if (_enabled)
{
_respawnTimer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMinutes(15.0), CheckRespawn);
}
else
{
_artifacts = null;
}
}
}
public class StealableEntry
{
public StealableEntry(Map map, Point3D location, int minDelay, int maxDelay, Type type, int hue = 0)
{
Map = map;
Location = location;
MinDelay = minDelay;
MaxDelay = maxDelay;
Type = type;
Hue = hue;
}
public Map Map { get; }
public Point3D Location { get; }
public int MinDelay { get; }
public int MaxDelay { get; }
public Type Type { get; }
public int Hue { get; }
public Item CreateInstance()
{
try
{
var item = Type.CreateInstance<Item>();
if (Hue > 0)
{
item.Hue = Hue;
}
item.Movable = false;
item.MoveToWorld(Location, Map);
return item;
}
catch (Exception e)
{
logger.Warning(e, $"Failed to construct stealable artifact: {Type.FullName}");
return null;
}
}
}
public class StealableInstance
{
private Item m_Item;
public StealableInstance(StealableEntry entry) : this(entry, null, Core.Now)
{
}
public StealableInstance(StealableEntry entry, Item item, DateTime nextRespawn)
{
m_Item = item;
NextRespawn = nextRespawn;
Entry = entry;
}
public StealableEntry Entry { get; }
public Item Item
{
get => m_Item;
set
{
if (m_Item != null && value == null)
{
var delay = Utility.RandomMinMax(Entry.MinDelay, Entry.MaxDelay);
NextRespawn = Core.Now + TimeSpan.FromMinutes(delay);
}
if (_enabled)
{
if (m_Item != null)
{
_table.Remove(m_Item);
}
if (value != null)
{
_table[value] = this;
}
}
m_Item = value;
}
}
public DateTime NextRespawn { get; set; }
public void CheckRespawn()
{
if (Item != null && (Item.Deleted || Item.Movable || Item.Parent != null))
{
Item = null;
}
if (Item == null && Core.Now >= NextRespawn)
{
Item = Entry.CreateInstance();
}
}
}
[ManualDirtyChecking]
[TypeAlias("Server.Items.StealableArtifactsSpawner")]
[Obsolete("Deprecated in favor of the static system. Only used for legacy deserialization")]
public class StealableArtifactsSpawner : Item
{
private StealableArtifactsSpawner()
{
Delete();
}
public StealableArtifactsSpawner(Serial serial) : base(serial)
{
}
public override void Serialize(IGenericWriter writer)
{
}
public override void Deserialize(IGenericReader reader)
{
base.Deserialize(reader);
_stealableArtifactsPersistence.Deserialize(reader);
Timer.DelayCall(Delete);
}
}
}