## 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.
540 lines
14 KiB
C#
540 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using ModernUO.Serialization;
|
|
using Server.Engines.Craft;
|
|
using Server.Mobiles;
|
|
using Server.Network;
|
|
using Server.Targeting;
|
|
|
|
namespace Server.Items;
|
|
|
|
public delegate void InstrumentPickedCallback(Mobile from, BaseInstrument instrument);
|
|
|
|
public enum InstrumentQuality
|
|
{
|
|
Low,
|
|
Regular,
|
|
Exceptional
|
|
}
|
|
|
|
[SerializationGenerator(4, false)]
|
|
public abstract partial class BaseInstrument : Item, ICraftable, ISlayer
|
|
{
|
|
private static readonly Dictionary<Mobile, BaseInstrument> _instruments = new();
|
|
|
|
[InvalidateProperties]
|
|
[SerializableField(2)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private string _crafter;
|
|
|
|
[InvalidateProperties]
|
|
[SerializableField(4)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private SlayerName _slayer;
|
|
|
|
[InvalidateProperties]
|
|
[SerializableField(5)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private SlayerName _slayer2;
|
|
|
|
[SerializableField(7)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _successSound;
|
|
|
|
[SerializableField(8)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _failureSound;
|
|
|
|
public BaseInstrument(int itemID, int wellSound, int badlySound) : base(itemID)
|
|
{
|
|
_successSound = wellSound;
|
|
_failureSound = badlySound;
|
|
_usesRemaining = Utility.RandomMinMax(InitMinUses, InitMaxUses);
|
|
}
|
|
|
|
public virtual int InitMinUses => Core.UOR ? 350 : 100;
|
|
public virtual int InitMaxUses => Core.UOR ? 450 : 150;
|
|
|
|
public virtual TimeSpan ChargeReplenishRate => TimeSpan.FromMinutes(5.0);
|
|
|
|
[SerializableProperty(0)]
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool ReplenishesCharges
|
|
{
|
|
get => _replenishesCharges;
|
|
set
|
|
{
|
|
if (value != _replenishesCharges && value)
|
|
{
|
|
LastReplenished = Core.Now;
|
|
}
|
|
|
|
_replenishesCharges = value;
|
|
this.MarkDirty();
|
|
}
|
|
}
|
|
|
|
[SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private DateTime _lastReplenished;
|
|
|
|
private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue)
|
|
{
|
|
CheckReplenishUses();
|
|
}
|
|
|
|
[SerializableProperty(3)]
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public InstrumentQuality Quality
|
|
{
|
|
get => _quality;
|
|
set
|
|
{
|
|
UnscaleUses();
|
|
_quality = value;
|
|
InvalidateProperties();
|
|
ScaleUses();
|
|
}
|
|
}
|
|
|
|
[SerializableProperty(6)]
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public int UsesRemaining
|
|
{
|
|
get
|
|
{
|
|
CheckReplenishUses();
|
|
return _usesRemaining;
|
|
}
|
|
set
|
|
{
|
|
_usesRemaining = value;
|
|
InvalidateProperties();
|
|
}
|
|
}
|
|
|
|
public virtual int OnCraft(
|
|
int quality, bool makersMark, Mobile from, CraftSystem craftSystem, Type typeRes, BaseTool tool,
|
|
CraftItem craftItem, int resHue
|
|
)
|
|
{
|
|
Quality = (InstrumentQuality)quality;
|
|
|
|
if (makersMark)
|
|
{
|
|
Crafter = from?.RawName;
|
|
}
|
|
|
|
// Publish 16 change
|
|
if (Core.LBR)
|
|
{
|
|
if (Quality == InstrumentQuality.Exceptional)
|
|
{
|
|
UsesRemaining += 100;
|
|
}
|
|
}
|
|
else if (Quality != InstrumentQuality.Regular)
|
|
{
|
|
UsesRemaining += (int)(UsesRemaining * ((int)Quality - 1) * 0.1);
|
|
}
|
|
|
|
return quality;
|
|
}
|
|
|
|
public void CheckReplenishUses(bool invalidate = true)
|
|
{
|
|
if (!_replenishesCharges || _usesRemaining >= InitMaxUses)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_lastReplenished + ChargeReplenishRate < Core.Now)
|
|
{
|
|
var timeDifference = Core.Now - _lastReplenished;
|
|
|
|
// How rude of TimeSpan to not allow timespan division.
|
|
_usesRemaining = Math.Min(
|
|
_usesRemaining + (int)(timeDifference.Ticks / ChargeReplenishRate.Ticks), InitMaxUses
|
|
);
|
|
|
|
// Don't use the property here otherwise you will get an infinite loop.
|
|
_lastReplenished = Core.Now;
|
|
|
|
if (invalidate)
|
|
{
|
|
InvalidateProperties();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void ScaleUses()
|
|
{
|
|
UsesRemaining = UsesRemaining * GetUsesScalar() / 100;
|
|
// InvalidateProperties();
|
|
}
|
|
|
|
public void UnscaleUses()
|
|
{
|
|
UsesRemaining = UsesRemaining * 100 / GetUsesScalar();
|
|
}
|
|
|
|
public int GetUsesScalar() => _quality == InstrumentQuality.Exceptional ? 200 : 100;
|
|
|
|
public void ConsumeUse(Mobile from)
|
|
{
|
|
// TODO: Confirm what must happen here?
|
|
|
|
if (UsesRemaining > 1)
|
|
{
|
|
--UsesRemaining;
|
|
}
|
|
else
|
|
{
|
|
from?.SendLocalizedMessage(502079); // The instrument played its last tune.
|
|
|
|
Delete();
|
|
}
|
|
}
|
|
|
|
public static BaseInstrument GetInstrument(Mobile from)
|
|
{
|
|
if (_instruments.TryGetValue(from, out var instrument) && instrument.IsChildOf(from.Backpack))
|
|
{
|
|
return instrument;
|
|
}
|
|
|
|
_instruments.Remove(from);
|
|
return null;
|
|
}
|
|
|
|
public static int GetBardRange(Mobile bard, SkillName skill) => 8 + (int)(bard.Skills[skill].Value / 15);
|
|
|
|
public static void PickInstrument(Mobile from, InstrumentPickedCallback callback)
|
|
{
|
|
var instrument = GetInstrument(from);
|
|
if (instrument != null)
|
|
{
|
|
callback?.Invoke(from, instrument);
|
|
}
|
|
else
|
|
{
|
|
from.SendLocalizedMessage(500617); // What instrument shall you play?
|
|
from.BeginTarget(1, false, TargetFlags.None, OnPickedInstrument, callback);
|
|
}
|
|
}
|
|
|
|
public static void OnPickedInstrument(Mobile from, object targeted, InstrumentPickedCallback callback)
|
|
{
|
|
if (targeted is not BaseInstrument instrument)
|
|
{
|
|
from.SendLocalizedMessage(500619); // That is not a musical instrument.
|
|
}
|
|
else
|
|
{
|
|
SetInstrument(from, instrument);
|
|
callback?.Invoke(from, instrument);
|
|
}
|
|
}
|
|
|
|
public static bool IsMageryCreature(BaseCreature bc) => bc?.AI == AIType.AI_Mage && bc.Skills.Magery.Base > 5.0;
|
|
|
|
public static bool IsFireBreathingCreature(BaseCreature bc) => bc?.GetAbility(MonsterAbilityType.FireBreath) != null;
|
|
|
|
public static bool IsPoisonImmune(BaseCreature bc) => bc?.PoisonImmune != null;
|
|
|
|
public static int GetPoisonLevel(BaseCreature bc) => (bc?.HitPoison?.Level ?? -1) + 1;
|
|
|
|
public static double GetBaseDifficulty(Mobile targ)
|
|
{
|
|
/* Difficulty TODO: Add another 100 points for each of the following abilities:
|
|
- Radiation or Aura Damage (Heat, Cold etc.)
|
|
- Summoning Undead
|
|
*/
|
|
|
|
// Before LBR, the success rate is actually your skill rate.
|
|
// We are going to fudge the numbers so it feels right without having two separate provocation calculations
|
|
// To do this, we should *undo* the 1.6x multiplier on HitsMaxSeed
|
|
var val = targ.HitsMax * (Core.LBR ? 1.6 : 0.625) + targ.StamMax + targ.ManaMax;
|
|
|
|
val += targ.SkillsTotal / 10.0;
|
|
|
|
if (val > 700)
|
|
{
|
|
val = 700 + (int)((val - 700) * (3.0 / 11));
|
|
}
|
|
|
|
var bc = targ as BaseCreature;
|
|
|
|
if (IsMageryCreature(bc))
|
|
{
|
|
val += 100;
|
|
}
|
|
|
|
if (IsFireBreathingCreature(bc))
|
|
{
|
|
val += 100;
|
|
}
|
|
|
|
if (IsPoisonImmune(bc))
|
|
{
|
|
val += 100;
|
|
}
|
|
|
|
if (targ is VampireBat or VampireBatFamiliar)
|
|
{
|
|
val += 100;
|
|
}
|
|
|
|
val += GetPoisonLevel(bc) * 20;
|
|
|
|
val /= 10;
|
|
|
|
if (bc?.IsParagon == true)
|
|
{
|
|
val += 40.0;
|
|
}
|
|
|
|
if (Core.SE && val > 160.0)
|
|
{
|
|
val = 160.0;
|
|
}
|
|
|
|
return val;
|
|
}
|
|
|
|
public double GetDifficultyFor(Mobile targ)
|
|
{
|
|
var val = GetBaseDifficulty(targ);
|
|
|
|
if (_quality == InstrumentQuality.Exceptional)
|
|
{
|
|
val -= 5.0; // 10%
|
|
}
|
|
|
|
if (_slayer != SlayerName.None)
|
|
{
|
|
var entry = SlayerGroup.GetEntryByName(_slayer);
|
|
|
|
if (entry != null)
|
|
{
|
|
if (entry.Slays(targ))
|
|
{
|
|
val -= 10.0; // 20%
|
|
}
|
|
else if (entry.Group.OppositionSuperSlays(targ))
|
|
{
|
|
val += 10.0; // -20%
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_slayer2 != SlayerName.None)
|
|
{
|
|
var entry = SlayerGroup.GetEntryByName(_slayer2);
|
|
|
|
if (entry != null)
|
|
{
|
|
if (entry.Slays(targ))
|
|
{
|
|
val -= 10.0; // 20%
|
|
}
|
|
else if (entry.Group.OppositionSuperSlays(targ))
|
|
{
|
|
val += 10.0; // -20%
|
|
}
|
|
}
|
|
}
|
|
|
|
return val;
|
|
}
|
|
|
|
public static void SetInstrument(Mobile from, BaseInstrument item)
|
|
{
|
|
_instruments[from] = item;
|
|
}
|
|
|
|
public override void GetProperties(IPropertyList list)
|
|
{
|
|
var oldUses = _usesRemaining;
|
|
CheckReplenishUses(false);
|
|
|
|
base.GetProperties(list);
|
|
|
|
if (_crafter != null)
|
|
{
|
|
list.Add(1050043, _crafter); // crafted by ~1_NAME~
|
|
}
|
|
|
|
if (_quality == InstrumentQuality.Exceptional)
|
|
{
|
|
list.Add(1060636); // exceptional
|
|
}
|
|
|
|
list.Add(1060584, _usesRemaining); // uses remaining: ~1_val~
|
|
|
|
if (_replenishesCharges)
|
|
{
|
|
list.Add(1070928); // Replenish Charges
|
|
}
|
|
|
|
if (_slayer != SlayerName.None)
|
|
{
|
|
var entry = SlayerGroup.GetEntryByName(_slayer);
|
|
if (entry != null)
|
|
{
|
|
list.Add(entry.Title);
|
|
}
|
|
}
|
|
|
|
if (_slayer2 != SlayerName.None)
|
|
{
|
|
var entry = SlayerGroup.GetEntryByName(_slayer2);
|
|
if (entry != null)
|
|
{
|
|
list.Add(entry.Title);
|
|
}
|
|
}
|
|
|
|
if (_usesRemaining != oldUses)
|
|
{
|
|
Timer.StartTimer(InvalidateProperties);
|
|
}
|
|
}
|
|
|
|
public override void OnSingleClick(Mobile from)
|
|
{
|
|
var attrs = new List<EquipInfoAttribute>();
|
|
|
|
if (DisplayLootType)
|
|
{
|
|
if (LootType == LootType.Blessed)
|
|
{
|
|
attrs.Add(new EquipInfoAttribute(1038021)); // blessed
|
|
}
|
|
else if (LootType == LootType.Cursed)
|
|
{
|
|
attrs.Add(new EquipInfoAttribute(1049643)); // cursed
|
|
}
|
|
}
|
|
|
|
if (_quality == InstrumentQuality.Exceptional)
|
|
{
|
|
attrs.Add(new EquipInfoAttribute(1018305 - (int)_quality));
|
|
}
|
|
|
|
if (_replenishesCharges)
|
|
{
|
|
attrs.Add(new EquipInfoAttribute(1070928)); // Replenish Charges
|
|
}
|
|
|
|
// TODO: Must this support item identification?
|
|
if (_slayer != SlayerName.None)
|
|
{
|
|
var entry = SlayerGroup.GetEntryByName(_slayer);
|
|
if (entry != null)
|
|
{
|
|
attrs.Add(new EquipInfoAttribute(entry.Title));
|
|
}
|
|
}
|
|
|
|
if (_slayer2 != SlayerName.None)
|
|
{
|
|
var entry = SlayerGroup.GetEntryByName(_slayer2);
|
|
if (entry != null)
|
|
{
|
|
attrs.Add(new EquipInfoAttribute(entry.Title));
|
|
}
|
|
}
|
|
|
|
int number;
|
|
|
|
if (Name == null)
|
|
{
|
|
number = LabelNumber;
|
|
}
|
|
else
|
|
{
|
|
LabelTo(from, Name);
|
|
number = 1041000;
|
|
}
|
|
|
|
if (attrs.Count == 0 && Crafter == null && Name != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
from.NetState.SendDisplayEquipmentInfo(Serial, number, _crafter, false, attrs);
|
|
}
|
|
|
|
private void Deserialize(IGenericReader reader, int version)
|
|
{
|
|
_replenishesCharges = reader.ReadBool();
|
|
|
|
if (_replenishesCharges)
|
|
{
|
|
_lastReplenished = reader.ReadDateTime();
|
|
}
|
|
|
|
Timer.DelayCall((item, crafter) => item._crafter = crafter?.RawName, this, reader.ReadEntity<Mobile>());
|
|
|
|
_quality = (InstrumentQuality)reader.ReadEncodedInt();
|
|
_slayer = (SlayerName)reader.ReadEncodedInt();
|
|
_slayer2 = (SlayerName)reader.ReadEncodedInt();
|
|
|
|
UsesRemaining = reader.ReadEncodedInt();
|
|
|
|
SuccessSound = reader.ReadEncodedInt();
|
|
FailureSound = reader.ReadEncodedInt();
|
|
}
|
|
|
|
[AfterDeserialization]
|
|
private void AfterDeserialization()
|
|
{
|
|
CheckReplenishUses(false);
|
|
}
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (!from.InRange(GetWorldLocation(), 1))
|
|
{
|
|
from.SendLocalizedMessage(500446); // That is too far away.
|
|
}
|
|
else if (from.BeginAction<BaseInstrument>())
|
|
{
|
|
SetInstrument(from, this);
|
|
|
|
// Delay of 6 second before being able to play another instrument again
|
|
Timer.StartTimer(TimeSpan.FromSeconds(6), from.EndAction<BaseInstrument>);
|
|
|
|
if (CheckMusicianship(from))
|
|
{
|
|
PlayInstrumentWell(from);
|
|
}
|
|
else
|
|
{
|
|
PlayInstrumentBadly(from);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
from.SendLocalizedMessage(500119); // You must wait to perform another action
|
|
}
|
|
}
|
|
|
|
public static bool CheckMusicianship(Mobile m)
|
|
{
|
|
m.CheckSkill(SkillName.Musicianship, 0.0, 120.0);
|
|
|
|
return m.Skills.Musicianship.Value / 100 > Utility.RandomDouble();
|
|
}
|
|
|
|
public void PlayInstrumentWell(Mobile from)
|
|
{
|
|
from.PlaySound(SuccessSound);
|
|
}
|
|
|
|
public void PlayInstrumentBadly(Mobile from)
|
|
{
|
|
from.PlaySound(FailureSound);
|
|
}
|
|
}
|