ModernUO/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs
Kamron Batman 8d1283ba27
feat: convert all delta-time serialization to anchored time
Every actively-written delta-time value now stores an absolute anchored
timestamp, shifted by downtime at load: remaining time is preserved across
restarts and idle saves become byte-stable.

- Item v11: LastMoved (was whole-minute delta, rewritten every save) and
  DecayResetTime; Mobile v38: the three stat-gain stamps; BaseCreature v21:
  SummonEnd; plus VendorInventory, StealableArtifacts, and ML quest objective
  timers in their own version gates.
- 17 code-generated classes swap [DeltaDateTime] for [AnchoredDateTime] with a
  version bump and MigrateFrom each (generated from their current migration
  schemas, so old saves replay the delta reads).
- GenericPersistence payloads carry no anchor of their own, so they inherit
  the save-wide shift: entity indexes always load first, and every file in a
  save shares one SaveStartTime - World.LoadTimeShift is stamped from the idx
  v5 header and applied to generic persistence readers. This is what makes
  VirtueContext and StealableArtifacts convertible at all.
- Legacy read sites (old-version fallbacks and migration replays) keep
  ReadDeltaTime: they decode old bytes and must never change.

Acceptance tests: item serialization is byte-identical across saves hours
apart, and LastMoved/DecayResetTime round-trip exactly at sub-minute
precision (impossible under the old minutes encoding).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:19:42 -07:00

99 lines
2.6 KiB
C#

using System;
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(2, false)]
public partial class TransientItem : Item
{
private void MigrateFrom(V1Content content)
{
_expiration = content.Expiration;
}
private TimerExecutionToken _timerToken;
[AnchoredDateTime]
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _expiration;
[Constructible]
public TransientItem(int itemID, TimeSpan lifeSpan) : base(itemID)
{
_expiration = Core.Now + lifeSpan;
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
public override bool Nontransferable => true;
public virtual TextDefinition InvalidTransferMessage => TextDefinition.Empty;
public override void HandleInvalidTransfer(Mobile from)
{
InvalidTransferMessage.SendMessageTo(from);
Delete();
}
public virtual void Expire(Mobile parent)
{
parent?.SendLocalizedMessage(1072515, Name ?? $"#{LabelNumber}"); // The ~1_name~ expired...
Effects.PlaySound(GetWorldLocation(), Map, 0x201);
Delete();
}
public virtual void SendTimeRemainingMessage(Mobile to)
{
var remaining = Utility.Max(_expiration - Core.Now, TimeSpan.Zero);
to.SendLocalizedMessage(
1072516, // ~1_name~ will expire in ~2_val~ seconds!
$"{Name ?? $"#{LabelNumber}"}\t{remaining.TotalSeconds:F0}"
);
}
public override void OnDelete()
{
_timerToken.Cancel();
base.OnDelete();
}
public virtual void CheckExpiry()
{
if (_expiration - Core.Now <= TimeSpan.Zero)
{
Expire(RootParent as Mobile);
}
else
{
InvalidateProperties();
}
}
public void ResetExpiration(TimeSpan duration) => Expiration = Core.Now + duration;
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
var remaining = Utility.Max(_expiration - Core.Now, TimeSpan.Zero);
list.Add(1072517, $"{remaining.TotalSeconds:F0}"); // Lifespan: ~1_val~ seconds
}
private void Deserialize(IGenericReader reader, int version)
{
var lifespan = reader.ReadTimeSpan();
var creationTime = reader.ReadDateTime(); // CreationTime
_expiration = creationTime + lifespan;
}
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
}