## Summary
Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate.
```cs
// before
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int Charges
{
get => _charges;
set
{
_charges = Math.Clamp(value, 0, MaxCharges);
InvalidateProperties();
this.MarkDirty();
}
}
// after
[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;
}
```
## How sites were selected
A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately:
- **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these.
- **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight).
- **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field).
Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`).
## Verification
- Build: **0 errors, 0 warnings**.
- **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it.
- **835 + 708 tests green.**
## Behavioral notes (all strict improvements, called out for review)
- Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes).
- Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction.
419 lines
12 KiB
C#
419 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using ModernUO.Serialization;
|
|
using Server.Targeting;
|
|
|
|
namespace Server.Items;
|
|
|
|
public class CrystalRechargeInfo
|
|
{
|
|
private static CrystalRechargeInfo[] _table =
|
|
{
|
|
new(typeof(Citrine), 500),
|
|
new(typeof(Amber), 500),
|
|
new(typeof(Tourmaline), 750),
|
|
new(typeof(Emerald), 1000),
|
|
new(typeof(Sapphire), 1000),
|
|
new(typeof(Amethyst), 1000),
|
|
new(typeof(StarSapphire), 1250),
|
|
new(typeof(Diamond), 2000)
|
|
};
|
|
|
|
private CrystalRechargeInfo(Type type, int amount)
|
|
{
|
|
Type = type;
|
|
Amount = amount;
|
|
}
|
|
|
|
public Type Type { get; }
|
|
|
|
public int Amount { get; }
|
|
|
|
public static CrystalRechargeInfo Get(Type type)
|
|
{
|
|
foreach (var info in _table)
|
|
{
|
|
if (info.Type == type)
|
|
{
|
|
return info;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
[SerializationGenerator(1)]
|
|
public partial class BroadcastCrystal : Item
|
|
{
|
|
public const int MaxCharges = 2000;
|
|
|
|
[InvalidateProperties]
|
|
[SerializableField(0)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _charges;
|
|
|
|
[InvalidateProperties]
|
|
[SerializableField(1, getter: "private", setter: "private")]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private List<ReceiverCrystal> _receivers;
|
|
|
|
[Constructible]
|
|
public BroadcastCrystal(int charges = 2000) : base(0x1ED0)
|
|
{
|
|
Light = LightType.Circle150;
|
|
_charges = charges;
|
|
_receivers = new List<ReceiverCrystal>();
|
|
}
|
|
|
|
public override int LabelNumber => 1060740; // communication crystal
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool Active
|
|
{
|
|
get => ItemID == 0x1ECD;
|
|
set
|
|
{
|
|
ItemID = value ? 0x1ECD : 0x1ED0;
|
|
InvalidateProperties();
|
|
}
|
|
}
|
|
|
|
public override bool HandlesOnSpeech => true;
|
|
|
|
public override void GetProperties(IPropertyList list)
|
|
{
|
|
base.GetProperties(list);
|
|
|
|
list.Add(Active ? 1060742 : 1060743); // active / inactive
|
|
list.Add(1060745); // broadcast
|
|
list.Add(1060741, Charges); // charges: ~1_val~
|
|
|
|
if (_receivers.Count > 0)
|
|
{
|
|
list.Add(1060746, _receivers.Count); // links: ~1_val~
|
|
}
|
|
}
|
|
|
|
public override void OnSingleClick(Mobile from)
|
|
{
|
|
base.OnSingleClick(from);
|
|
|
|
LabelTo(from, Active ? 1060742 : 1060743); // active / inactive
|
|
LabelTo(from, 1060745); // broadcast
|
|
LabelTo(from, 1060741, Charges.ToString()); // charges: ~1_val~
|
|
|
|
if (_receivers.Count > 0)
|
|
{
|
|
LabelTo(from, 1060746, _receivers.Count.ToString()); // links: ~1_val~
|
|
}
|
|
}
|
|
|
|
private void Deserialize(IGenericReader reader, int version)
|
|
{
|
|
_charges = reader.ReadEncodedInt();
|
|
_receivers = reader.ReadEntityList<ReceiverCrystal>();
|
|
}
|
|
|
|
public override void OnSpeech(SpeechEventArgs e)
|
|
{
|
|
if (!Active || Receivers.Count == 0 || RootParent != null && RootParent is not Mobile || e.Type == MessageType.Emote)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var from = e.Mobile;
|
|
var speech = e.Speech;
|
|
|
|
foreach (var receiver in new List<ReceiverCrystal>(Receivers))
|
|
{
|
|
if (receiver.Deleted)
|
|
{
|
|
RemoveReceiver(receiver);
|
|
}
|
|
else if (Charges > 0)
|
|
{
|
|
receiver.TransmitMessage(from, speech);
|
|
Charges--;
|
|
}
|
|
else
|
|
{
|
|
Active = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void AddReceiver(ReceiverCrystal receiver)
|
|
{
|
|
AddToReceivers(receiver);
|
|
InvalidateProperties();
|
|
}
|
|
|
|
public void RemoveReceiver(ReceiverCrystal receiver)
|
|
{
|
|
RemoveFromReceivers(receiver);
|
|
InvalidateProperties();
|
|
}
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (!from.InRange(GetWorldLocation(), 2))
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
|
|
return;
|
|
}
|
|
|
|
from.Target = new InternalTarget(this);
|
|
}
|
|
|
|
private class InternalTarget : Target
|
|
{
|
|
private readonly BroadcastCrystal m_Crystal;
|
|
|
|
public InternalTarget(BroadcastCrystal crystal) : base(2, false, TargetFlags.None) => m_Crystal = crystal;
|
|
|
|
protected override void OnTarget(Mobile from, object targeted)
|
|
{
|
|
if (!m_Crystal.IsAccessibleTo(from))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (from.Map != m_Crystal.Map || !from.InRange(m_Crystal.GetWorldLocation(), 2))
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
|
|
return;
|
|
}
|
|
|
|
if (targeted == m_Crystal)
|
|
{
|
|
if (m_Crystal.Active)
|
|
{
|
|
m_Crystal.Active = false;
|
|
from.SendLocalizedMessage(500672); // You turn the crystal off.
|
|
}
|
|
else
|
|
{
|
|
if (m_Crystal.Charges > 0)
|
|
{
|
|
m_Crystal.Active = true;
|
|
from.SendLocalizedMessage(500673); // You turn the crystal on.
|
|
}
|
|
else
|
|
{
|
|
from.SendLocalizedMessage(500676); // This crystal is out of charges.
|
|
}
|
|
}
|
|
}
|
|
else if (targeted is ReceiverCrystal receiver)
|
|
{
|
|
if (m_Crystal.Receivers.Count >= 10)
|
|
{
|
|
from.SendLocalizedMessage(1010042); // This broadcast crystal is already linked to 10 receivers.
|
|
}
|
|
else if (receiver.Sender == m_Crystal)
|
|
{
|
|
from.SendLocalizedMessage(500674); // This crystal is already linked with that crystal.
|
|
}
|
|
else if (receiver.Sender != null)
|
|
{
|
|
// That receiver crystal is already linked to another broadcast crystal.
|
|
from.SendLocalizedMessage(1010043);
|
|
}
|
|
else
|
|
{
|
|
receiver.Sender = m_Crystal;
|
|
from.SendLocalizedMessage(500675); // That crystal has been linked to this crystal.
|
|
}
|
|
}
|
|
else if (targeted == from)
|
|
{
|
|
foreach (var rc in new List<ReceiverCrystal>(m_Crystal.Receivers))
|
|
{
|
|
rc.Sender = null;
|
|
}
|
|
|
|
from.SendLocalizedMessage(1010046); // You unlink the broadcast crystal from all of its receivers.
|
|
}
|
|
else if (targeted is not Item targItem || !targItem.VerifyMove(from))
|
|
{
|
|
from.SendLocalizedMessage(500681); // You cannot use this crystal on that.
|
|
}
|
|
else
|
|
{
|
|
var info = CrystalRechargeInfo.Get(targItem.GetType());
|
|
|
|
if (info == null)
|
|
{
|
|
from.SendLocalizedMessage(500681); // You cannot use this crystal on that.
|
|
}
|
|
else if (m_Crystal.Charges >= MaxCharges)
|
|
{
|
|
from.SendLocalizedMessage(500678); // This crystal is already fully charged.
|
|
}
|
|
else
|
|
{
|
|
targItem.Consume();
|
|
|
|
if (m_Crystal.Charges + info.Amount >= MaxCharges)
|
|
{
|
|
m_Crystal.Charges = MaxCharges;
|
|
from.SendLocalizedMessage(500679); // You completely recharge the crystal.
|
|
}
|
|
else
|
|
{
|
|
m_Crystal.Charges += info.Amount;
|
|
from.SendLocalizedMessage(500680); // You recharge the crystal.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
[SerializationGenerator(0)]
|
|
public partial class ReceiverCrystal : Item
|
|
{
|
|
[SerializableField(0, fieldChanged: nameof(OnSenderChanged))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private BroadcastCrystal _sender;
|
|
|
|
private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue)
|
|
{
|
|
oldValue?.RemoveReceiver(this);
|
|
newValue?.AddReceiver(this);
|
|
}
|
|
|
|
[Constructible]
|
|
public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150;
|
|
|
|
public ReceiverCrystal(Serial serial) : base(serial)
|
|
{
|
|
}
|
|
|
|
public override int LabelNumber => 1060740; // communication crystal
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool Active
|
|
{
|
|
get => ItemID == 0x1ED1;
|
|
set
|
|
{
|
|
ItemID = value ? 0x1ED1 : 0x1ED0;
|
|
InvalidateProperties();
|
|
}
|
|
}
|
|
|
|
public override void GetProperties(IPropertyList list)
|
|
{
|
|
base.GetProperties(list);
|
|
|
|
list.Add(Active ? 1060742 : 1060743); // active / inactive
|
|
list.Add(1060744); // receiver
|
|
}
|
|
|
|
public override void OnSingleClick(Mobile from)
|
|
{
|
|
base.OnSingleClick(from);
|
|
|
|
LabelTo(from, Active ? 1060742 : 1060743); // active / inactive
|
|
LabelTo(from, 1060744); // receiver
|
|
}
|
|
|
|
public void TransmitMessage(Mobile from, string message)
|
|
{
|
|
if (!Active)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var text = $"{from.Name} says {message}";
|
|
|
|
if (RootParent is Mobile mobile)
|
|
{
|
|
mobile.SendMessage(0x2B2, $"Crystal: {text}");
|
|
}
|
|
else if (RootParent is Item item)
|
|
{
|
|
item.PublicOverheadMessage(MessageType.Regular, 0x2B2, false, $"Crystal: {text}");
|
|
}
|
|
else
|
|
{
|
|
PublicOverheadMessage(MessageType.Regular, 0x2B2, false, text);
|
|
}
|
|
}
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (!from.InRange(GetWorldLocation(), 2))
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
|
|
return;
|
|
}
|
|
|
|
from.Target = new InternalTarget(this);
|
|
}
|
|
|
|
private class InternalTarget : Target
|
|
{
|
|
private readonly ReceiverCrystal m_Crystal;
|
|
|
|
public InternalTarget(ReceiverCrystal crystal) : base(-1, false, TargetFlags.None) => m_Crystal = crystal;
|
|
|
|
protected override void OnTarget(Mobile from, object targeted)
|
|
{
|
|
if (!m_Crystal.IsAccessibleTo(from))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (from.Map != m_Crystal.Map || !from.InRange(m_Crystal.GetWorldLocation(), 2))
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
|
|
return;
|
|
}
|
|
|
|
if (targeted == m_Crystal)
|
|
{
|
|
if (m_Crystal.Active)
|
|
{
|
|
m_Crystal.Active = false;
|
|
from.SendLocalizedMessage(500672); // You turn the crystal off.
|
|
}
|
|
else
|
|
{
|
|
m_Crystal.Active = true;
|
|
from.SendLocalizedMessage(500673); // You turn the crystal on.
|
|
}
|
|
}
|
|
else if (targeted == from)
|
|
{
|
|
if (m_Crystal.Sender != null)
|
|
{
|
|
m_Crystal.Sender = null;
|
|
from.SendLocalizedMessage(1010044); // You unlink the receiver crystal.
|
|
}
|
|
else
|
|
{
|
|
from.SendLocalizedMessage(1010045); // That receiver crystal is not linked.
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (targeted is Item targItem && targItem.VerifyMove(from))
|
|
{
|
|
var info = CrystalRechargeInfo.Get(targItem.GetType());
|
|
|
|
if (info != null)
|
|
{
|
|
from.SendLocalizedMessage(500677); // This crystal cannot be recharged.
|
|
return;
|
|
}
|
|
}
|
|
|
|
from.SendLocalizedMessage(1010045); // That receiver crystal is not linked.
|
|
}
|
|
}
|
|
}
|
|
}
|