ModernUO/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs
Kamron Batman 73f9688083
feat: adopt serialization generator v4 (field-side linkage, anchored timers) (#2586)
## Summary

Adopts ModernUO.Serialization 4.0.0 across the engine. Three commits, reviewable independently:

1. **Package + tool bump to 4.0.0** (`Server.csproj`, `UOContent.csproj`, `dotnet-tools.json`).
2. **Timers → `[DeserializeTimer]`** — the 8 drifting timers (BaseLight, TreasureMapChest, MarkContainer, FillableContainer, DeathRobe, DecayedCorpse, Corpse, BaseEscortable) now store their next tick as **anchored time**: server downtime no longer consumes the remaining delay, and idle-world saves are byte-stable. This changes their wire format, so each class bumps its serialization version with a `MigrateFrom` that replays the old delta-time read through the migration schema (the new `vN.json` files carry `@AnchoredTimer`; the old ones keep `@TimerDrift`, which the generator reads forever). The 2 wall-clock timers (Aquarium, FountainOfLife) keep their exact format via `wallClock: true` — no bump. Restart methods drop their `TimeSpan.MinValue` sentinel checks: v4 invokes them **only when a timer was actually running at save**.
3. **Linkage → field-side declarations** — 175 conversions across 25 files: `[SerializableFieldSaveFlag(order)]`/`[SerializableFieldDefault(order)]` become `[SaveFlag(nameof(...), nameof(...))]` on the field, and `[SerializableFieldChanged(order)]` becomes the `fieldChanged:` argument of `[SerializableField]`. **Wire-neutral: zero migration schemas changed.**

## Verification

- Solution builds with **0 errors, 0 warnings**; all three 4.0.0 packages verified indexed on nuget.org (no local feed needed).
- **835 + 708 tests green.**
- Generated output inspected: old-version content structs replay `ReadDeltaTime` (e.g. `V3Content.DecayTimerNext = reader.ReadDeltaTime()`), current versions write/read anchored time with the gated restart, and the wall-clock classes emit byte-identical `Write`/`ReadDateTime` framing.
- Schema tool run is committed (CI's `git diff --exit-code` schema check passes): exactly the 8 expected new `vN.json` files, nothing else touched.
- The conversion was scripted with a class-scoped resolver (order → same-class `[SerializableField(order)]`/`[SerializableProperty(order)]`); it planned 175/175 with zero ambiguities before applying.

## Notes

- New `MigrateFrom`s use the content structs' provided `XxxDelay` property, matching the pre-existing idiom in Corpse's and TreasureMapChest's older migrations.
- Follow-up candidate (separate PR, wire-neutral, any time): fold the ~150 eligible hand-written `[SerializableProperty]` setters (clamps, post-change side effects) down to `[SerializableField]` with `allowFieldChange`/`fieldChanged` hooks.
2026-08-22 17:54:02 -07:00

245 lines
5.6 KiB
C#

using System;
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(3, false)]
public abstract partial class FillableContainer : LockableContainer
{
[SerializableField(1)]
[DeserializeTimer(nameof(DeserializeRespawnTimer))]
private Timer _respawnTimer;
private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn);
private void MigrateFrom(V2Content content)
{
_contentType = content.ContentType;
if (content.RespawnTimerDelay != TimeSpan.MinValue)
{
DeserializeRespawnTimer(content.RespawnTimerDelay);
}
}
public FillableContainer(int itemID) : base(itemID)
{
Movable = false;
_contentType = FillableContentType.None;
}
public virtual int MinRespawnMinutes => 60;
public virtual int MaxRespawnMinutes => 90;
public virtual bool IsLockable => true;
public virtual bool IsTrappable => IsLockable;
public virtual int SpawnThreshold => 2;
[CommandProperty(AccessLevel.GameMaster)]
public DateTime NextRespawnTime => _respawnTimer?.Next ?? DateTime.MinValue;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public FillableContentType ContentType
{
get => _contentType;
set
{
if (_contentType == value)
{
return;
}
ClearContents();
_contentType = value;
Respawn();
}
}
protected void ClearContents()
{
for (var i = Items.Count - 1; i >= 0; --i)
{
if (i < Items.Count)
{
Items[i].Delete();
}
}
}
public override void OnMapChange()
{
base.OnMapChange();
AcquireContent();
}
public override void OnLocationChange(Point3D oldLocation)
{
base.OnLocationChange(oldLocation);
AcquireContent();
}
public virtual void AcquireContent()
{
if (_contentType == FillableContentType.None)
{
_contentType = FillableContent.Acquire(GetWorldLocation(), Map);
}
if (_contentType != FillableContentType.None)
{
Respawn();
}
}
public override void LockPick(Mobile from)
{
base.LockPick(from);
CheckRespawn();
}
public override void OnItemRemoved(Item item)
{
CheckRespawn();
}
public override void OnAfterDelete()
{
base.OnAfterDelete();
_respawnTimer?.Stop();
_respawnTimer = null;
}
public int GetItemsCount()
{
var count = 0;
foreach (var item in Items)
{
count += item.Amount;
}
return count;
}
public void CheckRespawn()
{
if (_respawnTimer?.Running == true)
{
return;
}
var delay = TimeSpan.FromMinutes(Utility.RandomMinMax(MinRespawnMinutes, MaxRespawnMinutes));
_respawnTimer = Timer.DelayCall(delay, Respawn);
}
public void Respawn()
{
_respawnTimer?.Stop();
_respawnTimer = null;
if (_contentType == FillableContentType.None || Deleted)
{
return;
}
GenerateContent();
var level = FillableContent.Lookup(_contentType).Level;
if (IsLockable)
{
Locked = true;
var difficulty = (level - 1) * 30;
LockLevel = difficulty - 10;
MaxLockLevel = difficulty + 30;
RequiredSkill = difficulty;
}
if (IsTrappable && (level > 1 || Utility.Random(5) < 4))
{
TrapType = level > Utility.Random(5) ? TrapType.PoisonTrap : TrapType.ExplosionTrap;
TrapPower = level * Utility.RandomMinMax(10, 30);
TrapLevel = level;
}
else
{
TrapType = TrapType.None;
TrapPower = 0;
TrapLevel = 0;
}
}
protected virtual int GetSpawnCount()
{
var itemsCount = GetItemsCount();
if (itemsCount > SpawnThreshold)
{
return 0;
}
var maxSpawnCount = (1 + SpawnThreshold - itemsCount) * 2;
return Utility.RandomMinMax(0, maxSpawnCount);
}
public virtual void GenerateContent()
{
if (_contentType == FillableContentType.None || Deleted)
{
return;
}
var content = FillableContent.Lookup(_contentType);
var toSpawn = GetSpawnCount();
for (var i = 0; i < toSpawn; ++i)
{
var item = content.Construct();
if (item == null)
{
continue;
}
var list = Items;
for (var j = 0; j < list.Count; ++j)
{
var subItem = list[j];
if (subItem is not Container && subItem.StackWith(null, item, false))
{
break;
}
}
if (!item.Deleted)
{
DropItem(item);
}
}
}
private void Deserialize(IGenericReader reader, int version)
{
_contentType = (FillableContentType)reader.ReadInt();
var respawnTimerNext = reader.ReadDeltaTime();
DeserializeRespawnTimer(respawnTimerNext == DateTime.MinValue ? TimeSpan.MinValue : respawnTimerNext - Core.Now);
}
[AfterDeserialization]
private void AfterDeserialization()
{
if (_respawnTimer?.Running != true)
{
CheckRespawn();
}
}
}