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>
99 lines
2.6 KiB
C#
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);
|
|
}
|
|
}
|