ModernUO/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs
Kamron Batman a2c232f4a8
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.
2026-09-06 09:39:00 -07:00

438 lines
12 KiB
C#

using System;
using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
using Server.Factions;
using Server.Misc;
using Server.Mobiles;
using Server.Prompts;
using Server.Regions;
using Server.Spells;
using Server.Targeting;
namespace Server.Items;
[SerializationGenerator(2)]
public partial class BraceletOfBinding : BaseBracelet, TranslocationItem
{
[InvalidateProperties]
[SerializableField(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _inscription;
private TransportTimer _timer;
[Constructible]
public BraceletOfBinding() : base(0x1086) => Hue = 0x489;
public override double DefaultWeight => 1.0;
[SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _recharges;
private bool AllowRechargesChange(ref int value)
{
value = Math.Clamp(value, 0, MaxRecharges);
return true;
}
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;
private bool AllowChargesChange(ref int value)
{
value = Math.Clamp(value, 0, MaxCharges);
return true;
}
[SerializableProperty(3)]
[CommandProperty(AccessLevel.GameMaster)]
public BraceletOfBinding Bound
{
get => _bound?.Deleted != false ? null : _bound;
set
{
if (_bound != value)
{
_bound = value;
this.MarkDirty();
}
}
}
[CommandProperty(AccessLevel.GameMaster)]
public int MaxCharges => 20;
[CommandProperty(AccessLevel.GameMaster)]
public int MaxRecharges => 255;
private static TextDefinition _translocationItemName = "bracelet of binding";
public TextDefinition TranslocationItemName => _translocationItemName;
public override void AddNameProperty(IPropertyList list)
{
// a bracelet of binding : ~1_val~ ~2_val~
list.Add(1054000, $"{_charges}\t{_inscription.DefaultIfNullOrEmpty(" ")}");
}
public override void OnSingleClick(Mobile from)
{
// a bracelet of binding : ~1_val~ ~2_val~
LabelTo(from, 1054000, $"{_charges}\t{_inscription.DefaultIfNullOrEmpty(" ")}");
}
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (from.Alive && IsChildOf(from))
{
var bound = Bound;
list.Add(new BraceletEntry(Activate, 6170, bound != null));
list.Add(new BraceletEntry(Search, 6171, bound != null));
list.Add(new BraceletEntry(Bind, bound == null ? 6173 : 6174, true));
list.Add(new BraceletEntry(Inscribe, 6175, true));
}
}
public override void OnDoubleClick(Mobile from)
{
if (Bound == null)
{
Bind(from);
}
else
{
Activate(from);
}
}
public void Activate(Mobile from)
{
if (Deleted || Bound == null)
{
return;
}
if (!IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else if (_timer != null)
{
// The bracelet is already attempting contact. You decide to wait a moment.
from.SendLocalizedMessage(1054013);
}
else
{
from.PlaySound(0xF9);
// * You concentrate on the bracelet to summon its power *
from.LocalOverheadMessage(MessageType.Regular, 0x5D, 1151783);
from.Frozen = true;
_timer = new TransportTimer(this, from);
_timer.Start();
}
}
public void Search(Mobile from)
{
var bound = Bound;
if (Deleted || bound == null)
{
return;
}
if (!IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else
{
CheckUse(from, true);
}
}
private bool CheckUse(Mobile from, bool successMessage)
{
var bound = Bound;
if (bound == null)
{
return false;
}
var boundRoot = bound.RootParent as Mobile;
if (Charges == 0)
{
// The bracelet glows black. It must be charged before it can be used again.
from.SendLocalizedMessage(1054005);
return false;
}
if (from.FindItemOnLayer(Layer.Bracelet) != this)
{
from.SendLocalizedMessage(1054004); // You must equip the bracelet in order to use its power.
return false;
}
if (boundRoot?.NetState == null || boundRoot.FindItemOnLayer(Layer.Bracelet) != bound)
{
// The bracelet emits a red glow. The bracelet's twin is not available for transport.
from.SendLocalizedMessage(1054006);
return false;
}
if (!Core.AOS && from.Map != boundRoot.Map)
{
from.SendLocalizedMessage(1054014); // The bracelet glows black. The bracelet's target is on another facet.
return false;
}
if (Sigil.ExistsOn(from))
{
from.SendLocalizedMessage(1061632); // You can't do that while carrying the sigil.
return false;
}
if (!SpellHelper.CheckTravel(from, TravelCheckType.RecallFrom, out var failureMessage))
{
failureMessage.SendMessageTo(from);
return false;
}
if (!SpellHelper.CheckTravel(from, boundRoot.Map, boundRoot.Location, TravelCheckType.RecallTo, out failureMessage))
{
failureMessage.SendMessageTo(from);
return false;
}
if (boundRoot.Map == Map.Felucca && from is PlayerMobile mobile && mobile.Young)
{
mobile.SendLocalizedMessage(1049543); // You decide against traveling to Felucca while you are still young.
return false;
}
if (from.Murderer && boundRoot.Map != Map.Felucca)
{
from.SendLocalizedMessage(1019004); // You are not allowed to travel there.
return false;
}
if (from.Criminal)
{
from.SendLocalizedMessage(1005561, "", 0x22); // Thou'rt a criminal and cannot escape so easily.
return false;
}
if (SpellHelper.CheckCombat(from))
{
from.SendLocalizedMessage(1005564, "", 0x22); // Wouldst thou flee during the heat of battle??
return false;
}
if (StaminaSystem.IsOverloaded(from))
{
from.SendLocalizedMessage(502359, "", 0x22); // Thou art too encumbered to move.
return false;
}
if (from.Region.IsPartOf<JailRegion>())
{
from.SendLocalizedMessage(1114345, "", 0x35); // You'll need a better jailbreak plan than that!
return false;
}
if (boundRoot.Region.IsPartOf<JailRegion>())
{
from.SendLocalizedMessage(1019004); // You are not allowed to travel there.
return false;
}
if (successMessage)
{
from.SendLocalizedMessage(1054015); // The bracelet's twin is available for transport.
}
return true;
}
public void Bind(Mobile from)
{
if (Deleted)
{
return;
}
if (!IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else
{
from.SendLocalizedMessage(1054001); // Target the bracelet of binding you wish to bind this bracelet to.
from.Target = new BindTarget(this);
}
}
public void Inscribe(Mobile from)
{
if (Deleted)
{
return;
}
if (!IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else
{
from.SendLocalizedMessage(1054009); // Enter the text to inscribe upon the bracelet :
from.Prompt = new InscribePrompt(this);
}
}
private void Deserialize(IGenericReader reader, int version)
{
_recharges = reader.ReadEncodedInt();
_charges = Math.Min(reader.ReadEncodedInt(), MaxCharges);
_inscription = reader.ReadString();
_bound = (BraceletOfBinding)reader.ReadEntity<Item>();
}
private delegate void BraceletCallback(Mobile from);
private class BraceletEntry : ContextMenuEntry
{
private readonly BraceletCallback _callback;
public BraceletEntry(BraceletCallback callback, int number, bool enabled) : base(number)
{
_callback = callback;
Enabled = enabled;
}
public override void OnClick(Mobile from, IEntity target)
{
if (from.CheckAlive())
{
_callback(from);
}
}
}
private class TransportTimer : Timer
{
private readonly BraceletOfBinding _bracelet;
private readonly Mobile _from;
public TransportTimer(BraceletOfBinding bracelet, Mobile from) : base(TimeSpan.FromSeconds(2.0))
{
_bracelet = bracelet;
_from = from;
}
protected override void OnTick()
{
_bracelet._timer = null;
_from.Frozen = false;
if (_bracelet.Deleted || _from.Deleted ||
!_bracelet.CheckUse(_from, false) ||
_bracelet.Bound.RootParent is not Mobile boundRoot)
{
return;
}
_bracelet.Charges--;
BaseCreature.TeleportPets(_from, boundRoot.Location, boundRoot.Map, true);
_from.PlaySound(0x1FC);
_from.MoveToWorld(boundRoot.Location, boundRoot.Map);
_from.PlaySound(0x1FC);
}
}
private class BindTarget : Target
{
private readonly BraceletOfBinding _bracelet;
public BindTarget(BraceletOfBinding bracelet) : base(-1, false, TargetFlags.None) => _bracelet = bracelet;
protected override void OnTarget(Mobile from, object targeted)
{
if (_bracelet.Deleted)
{
return;
}
if (!_bracelet.IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else if (targeted is BraceletOfBinding bindBracelet)
{
if (bindBracelet == _bracelet)
{
from.SendLocalizedMessage(1054012); // You cannot bind a bracelet of binding to itself!
}
else if (!bindBracelet.IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else
{
// You bind the bracelet to its counterpart. The bracelets glow with power.
from.SendLocalizedMessage(1054003);
from.PlaySound(0x1FA);
_bracelet.Bound = bindBracelet;
}
}
else
{
from.SendLocalizedMessage(1054002); // You can only bind this bracelet to another bracelet of binding!
}
}
}
private class InscribePrompt : Prompt
{
private readonly BraceletOfBinding _bracelet;
public InscribePrompt(BraceletOfBinding bracelet) => _bracelet = bracelet;
public override void OnResponse(Mobile from, string text)
{
if (_bracelet.Deleted)
{
return;
}
if (!_bracelet.IsChildOf(from))
{
from.SendLocalizedMessage(1042664); // You must have the object in your backpack to use it.
}
else
{
from.SendLocalizedMessage(1054011); // You mark the bracelet with your inscription.
_bracelet.Inscription = text;
}
}
public override void OnCancel(Mobile from)
{
from.SendLocalizedMessage(1054010); // You decide not to inscribe the bracelet at this time.
}
}
}